atris 3.71.1 → 3.71.3
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/atris/skills/campaigns/SKILL.md +10 -6
- package/atris.md +9 -0
- package/bin/atris.js +1 -1
- package/commands/campaign.js +219 -15
- package/commands/improve.js +3 -2
- package/commands/mission.js +33 -11
- package/commands/pulse.js +89 -5
- package/commands/worktree.js +24 -2
- package/lib/campaign-cadence.js +49 -13
- package/lib/campaign-core.js +72 -7
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +57 -0
- package/lib/campaign-loop.js +178 -33
- package/lib/campaign-protect.js +7 -1
- package/lib/campaign-rsi.js +680 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-trace.js +42 -20
- package/lib/mission-code-provenance.js +118 -0
- package/lib/pulse.js +2 -1
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: campaigns
|
|
3
|
-
description: "Run an Atris campaign: a long goal with a judge that fails today
|
|
3
|
+
description: "Run an Atris campaign: a long goal (hours to days) with a judge that fails today and members in seats, worked by missions until the judge passes. Use only when the path is unknown and takes many tries; a quick fix or a single mission is not a campaign. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
|
|
4
4
|
version: 1.0.0
|
|
5
5
|
tags:
|
|
6
6
|
- campaign
|
|
@@ -12,14 +12,18 @@ tags:
|
|
|
12
12
|
|
|
13
13
|
# Campaigns
|
|
14
14
|
|
|
15
|
-
A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions
|
|
15
|
+
A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions until the judge passes, a budget (cloud only) runs out, or a person has to decide something.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Size it first
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
- **Campaign**: the goal needs many missions, more than one member, or a spending cap. A campaign decides what to do next; its missions do the work.
|
|
19
|
+
Ask one question: do you already know the steps?
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
- **Quick fix** (minutes): you know the file and the fix. Edit it, run the tests that cover it, ship. No mission, no judge.
|
|
22
|
+
- **Mission** (an hour or a few): you know what done looks like and roughly how. One `atris mission start`, or a few across members.
|
|
23
|
+
- **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. A campaign decides what to do next; its missions do the work.
|
|
24
|
+
- **Purpose** (months to years): a standing campaign (`--standing`). Campaigns run under it (`--parent`), and each one's lessons feed the next.
|
|
25
|
+
|
|
26
|
+
A campaign is a vision, not a fix. If one mission would finish it, use a mission; if you could write the diff in ten minutes, write it. Measured 2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes.
|
|
23
27
|
|
|
24
28
|
## The five commands
|
|
25
29
|
|
package/atris.md
CHANGED
|
@@ -131,6 +131,7 @@ Six primitives, one sentence each:
|
|
|
131
131
|
- **The Mission** (MISSION.md): the enduring purpose. Never ends. Sits at the top and helps members pick goals.
|
|
132
132
|
- **Goals** (endgame, member goals): direction derived from the Mission. A goal points; it does not commit.
|
|
133
133
|
- **A mission run** (`atris mission`): a goal that signed a contract: objective + owner + verifier + stop condition. Ends in hours or days. Any engine can tick it.
|
|
134
|
+
- **A campaign** (`atris campaign`): a measurable goal with a judge that fails today, worked by many mission runs until it passes. Hours to days.
|
|
134
135
|
- **Tasks** (`atris task`): the smallest provable units. Receipts, then human accept.
|
|
135
136
|
- **Members** (`atris member`): the WHO. A role with memory and judgment doing one bounded, proven slice at a time.
|
|
136
137
|
- **Loops** (`atris loops`): the WHEN. Clocks that never finish, only fire. Health is exit 0 on cadence.
|
|
@@ -236,6 +237,14 @@ Before picking up work, decide scope:
|
|
|
236
237
|
- single project → route to that project's `atris/team/` and `atris task` queue
|
|
237
238
|
- crosses projects → route to `atris/team/cross-project-architect/` and plan the dependency order first
|
|
238
239
|
|
|
240
|
+
Then size it, before any tool runs. Ask one question: do I already know the steps?
|
|
241
|
+
- **Quick fix** (minutes): you know the file and the fix. Do it, run the tests that cover it, ship. No mission, no judge.
|
|
242
|
+
- **Mission** (an hour or a few): you know what done looks like and roughly how. One mission run, or a few across members (builder builds, validator checks).
|
|
243
|
+
- **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. `atris campaign` with a judge that fails today; its missions do the work.
|
|
244
|
+
- **Purpose** (months to years): a standing campaign under the Mission. Its judge runs on a schedule, campaigns run under it, and each one's lessons and scored record feed the next. This is the self-improving loop.
|
|
245
|
+
|
|
246
|
+
Never size up for show. A fix run as a campaign wastes time and inference and leaves bad data (2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes). Sizing is judgment, not a command: `atris wish` is for imagining, not for sizing.
|
|
247
|
+
|
|
239
248
|
The human is the constructor. You multiply. Handoff fidelity lives in the files, not in context.
|
|
240
249
|
|
|
241
250
|
## next
|
package/bin/atris.js
CHANGED
|
@@ -2018,7 +2018,7 @@ if (command === 'guide') {
|
|
|
2018
2018
|
.catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
|
|
2019
2019
|
} else if (command === 'campaign') {
|
|
2020
2020
|
Promise.resolve(require('../commands/campaign').campaignCommand(process.argv.slice(3)))
|
|
2021
|
-
.then((code) =>
|
|
2021
|
+
.then((code) => exitAfterStdoutDrain(code || 0))
|
|
2022
2022
|
.catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
|
|
2023
2023
|
} else if (command === 'who') {
|
|
2024
2024
|
Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
|
package/commands/campaign.js
CHANGED
|
@@ -11,12 +11,12 @@ const path = require('path');
|
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
12
|
const { readFlag } = require('../lib/arg-parser');
|
|
13
13
|
const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign-schema');
|
|
14
|
-
const { decide } = require('../lib/campaign-core');
|
|
14
|
+
const { decide, paysDollars } = require('../lib/campaign-core');
|
|
15
15
|
const { campaignDbPath, getCampaign, listCampaigns, newCampaignId, openCampaignStore } = require('../lib/campaign-store');
|
|
16
16
|
const { findHoldout, moveHoldoutBack, moveHoldoutIn, scrub } = require('../lib/campaign-holdout');
|
|
17
17
|
const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
|
|
18
18
|
const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
|
|
19
|
-
const { campaignHome, changedProtected, checkoutTop, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
|
|
19
|
+
const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
|
|
20
20
|
|
|
21
21
|
const JUDGE_TIMEOUT_SECONDS = 15 * 60;
|
|
22
22
|
// Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
|
|
@@ -50,8 +50,14 @@ function printHelp() {
|
|
|
50
50
|
console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
|
|
51
51
|
console.log(' add --work 50m --break 10m [--rounds N] to work in rounds; each break judges and writes lessons the next round plans from');
|
|
52
52
|
console.log(' atris campaign step <id> one step of that loop');
|
|
53
|
+
console.log(' atris campaign tick [--max-steps 4] [--parallel 1] push the next campaign here that has work, one mission at a time (the overnight heartbeat runs this)');
|
|
54
|
+
console.log(' atris campaign wake one step for every due campaign; a wait not yet due is skipped');
|
|
55
|
+
console.log(' atris campaign keep --install|--remove [--dry-run] a one-minute timer that runs wake (launchd or cron)');
|
|
53
56
|
console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
|
|
54
57
|
console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
|
|
58
|
+
console.log(' atris campaign rsi [id] [--no-write] distill trace rewards and lessons across campaigns');
|
|
59
|
+
console.log(' --loop [--rounds N] runs one real campaign step per cycle, rotating through open campaigns');
|
|
60
|
+
console.log(' --dry-run previews the cycles without running campaign work');
|
|
55
61
|
console.log(' atris campaign sync <id> trade events with the Atris cloud so web and iOS show it');
|
|
56
62
|
console.log(' start --cloud syncs at once; run syncs before and after every step of a synced campaign;');
|
|
57
63
|
console.log(' on a computer that has never seen <id>, sync pulls the whole campaign from the cloud');
|
|
@@ -211,6 +217,13 @@ function keepJudgeScript(judge, folder) {
|
|
|
211
217
|
return { from: file, judge: shellQuote(copy) };
|
|
212
218
|
}
|
|
213
219
|
|
|
220
|
+
// The commit saved on a campaign's start, or null when it started outside git.
|
|
221
|
+
function savedStartCommit(store, campaignId) {
|
|
222
|
+
if (!campaignId) return null;
|
|
223
|
+
const start = store.read(campaignId).find((event) => event.type === 'campaign_started');
|
|
224
|
+
return (start && start.data && start.data.start_commit) || null;
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
function makeContext(args, deps) {
|
|
215
228
|
const asJson = args.includes('--json');
|
|
216
229
|
const store = deps.store || openCampaignStore(process.cwd());
|
|
@@ -220,10 +233,15 @@ function makeContext(args, deps) {
|
|
|
220
233
|
// Held-out files live beside the campaign store, outside every checkout.
|
|
221
234
|
const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
|
|
222
235
|
// A campaign with a held-out file hands its path to the judge, and nothing
|
|
223
|
-
// the judge prints from it reaches the store.
|
|
224
|
-
|
|
236
|
+
// the judge prints from it reaches the store. A campaign started in a git
|
|
237
|
+
// checkout hands the judge the commit it started from. Start passes its
|
|
238
|
+
// commit in, since nothing is saved yet.
|
|
239
|
+
const judge = async (command, timeout, campaignId, startCommit = savedStartCommit(store, campaignId)) => {
|
|
225
240
|
const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
|
|
226
|
-
const
|
|
241
|
+
const env = { ...process.env };
|
|
242
|
+
if (holdout) env.ATRIS_CAMPAIGN_HOLDOUT = holdout;
|
|
243
|
+
if (startCommit) env.ATRIS_CAMPAIGN_START_COMMIT = startCommit;
|
|
244
|
+
const reading = await runJudge(command, timeout, env);
|
|
227
245
|
if (!holdout) return reading;
|
|
228
246
|
return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
|
|
229
247
|
};
|
|
@@ -239,7 +257,7 @@ function makeContext(args, deps) {
|
|
|
239
257
|
const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
|
|
240
258
|
// A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
|
|
241
259
|
const folder = deps.campaignFolder || ((id) => path.join(holdoutRoot, 'campaigns', id));
|
|
242
|
-
return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, cloud: deps.cloud || {} };
|
|
260
|
+
return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, keepDeps: deps.keep, cloud: deps.cloud || {} };
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
function requireCampaign(ctx, id) {
|
|
@@ -250,6 +268,14 @@ function requireCampaign(ctx, id) {
|
|
|
250
268
|
return { view };
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
// On a subscription the budget is still a cap, measured at API prices, so the
|
|
272
|
+
// person sees how much of it is used without a dollar figure they never paid.
|
|
273
|
+
function spentText(view, paid) {
|
|
274
|
+
if (paid) return `${money(view.budget.spent)} spent of ${money(view.budget.total)}`;
|
|
275
|
+
const used = view.budget.total > 0 ? Math.round((view.budget.spent / view.budget.total) * 100) : 0;
|
|
276
|
+
return `on your subscription, nothing spent · ${used}% of the budget used`;
|
|
277
|
+
}
|
|
278
|
+
|
|
253
279
|
function renderList(views) {
|
|
254
280
|
if (!views.length) {
|
|
255
281
|
return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
|
|
@@ -259,7 +285,7 @@ function renderList(views) {
|
|
|
259
285
|
for (const view of sorted) {
|
|
260
286
|
const running = view.missions.filter((m) => m.status === 'running').length;
|
|
261
287
|
lines.push(`${clip(view.title)} (${view.id})`);
|
|
262
|
-
const spend = noLimit(view) ? '' : ` · ${
|
|
288
|
+
const spend = noLimit(view) ? '' : ` · ${spentText(view, paysDollars(view))}`;
|
|
263
289
|
lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
|
|
264
290
|
lines.push(` next: ${nextMove(view).text}`);
|
|
265
291
|
}
|
|
@@ -274,14 +300,31 @@ function renderShow(view) {
|
|
|
274
300
|
const last = view.last_judge;
|
|
275
301
|
row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
|
|
276
302
|
row('answer', `${view.answer.confidence}${view.answer.text ? `: ${view.answer.text}` : ''}`);
|
|
277
|
-
if (!noLimit(view))
|
|
303
|
+
if (!noLimit(view)) {
|
|
304
|
+
const paid = paysDollars(view);
|
|
305
|
+
row('budget', paid ? `${spentText(view, paid)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left` : spentText(view, paid));
|
|
306
|
+
}
|
|
278
307
|
row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
|
|
279
308
|
if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
|
|
280
309
|
if (view.parent) row('parent', view.parent);
|
|
281
|
-
|
|
310
|
+
let ovrByMember = new Map();
|
|
311
|
+
try {
|
|
312
|
+
const { openCampaignStore: openStore } = require('../lib/campaign-store');
|
|
313
|
+
const { summarizeStoreRsi } = require('../lib/campaign-rsi');
|
|
314
|
+
const summary = summarizeStoreRsi(openStore(process.cwd()));
|
|
315
|
+
for (const m of summary.members || []) {
|
|
316
|
+
if (m && m.member && typeof m.overall === 'number') ovrByMember.set(m.member, m.overall);
|
|
317
|
+
}
|
|
318
|
+
} catch {}
|
|
319
|
+
const formatSeat = (s) => {
|
|
320
|
+
const ovr = ovrByMember.has(s.member) ? `${ovrByMember.get(s.member)} ovr` : '';
|
|
321
|
+
const detail = [s.role, ovr].filter(Boolean).join(' · ');
|
|
322
|
+
return detail ? `${s.member} (${detail})` : s.member;
|
|
323
|
+
};
|
|
324
|
+
row('seats', view.seats.length ? view.seats.map(formatSeat).join(', ') : 'none');
|
|
282
325
|
row('missions', view.missions.length ? '' : 'none');
|
|
283
326
|
for (const m of view.missions) {
|
|
284
|
-
const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
|
|
327
|
+
const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
|
|
285
328
|
lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
|
|
286
329
|
}
|
|
287
330
|
row('asks', view.asks.length ? '' : 'none');
|
|
@@ -352,7 +395,8 @@ async function cmdStart(args, ctx) {
|
|
|
352
395
|
|
|
353
396
|
const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
|
|
354
397
|
if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
|
|
355
|
-
const
|
|
398
|
+
const startCommit = headCommit(process.cwd());
|
|
399
|
+
const reading = await ctx.judge(judge, timeout, id, startCommit);
|
|
356
400
|
if (reading.passed && !standing) {
|
|
357
401
|
moveHoldoutBack(held);
|
|
358
402
|
return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
|
|
@@ -371,6 +415,7 @@ async function cmdStart(args, ctx) {
|
|
|
371
415
|
if (bet && !wrongIf) startData.bet = bet;
|
|
372
416
|
if (standing) startData.standing = true;
|
|
373
417
|
if (basis) startData.basis = basis;
|
|
418
|
+
if (startCommit) startData.start_commit = startCommit;
|
|
374
419
|
ctx.append(id, 'campaign_started', startData);
|
|
375
420
|
if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
|
|
376
421
|
if (parent) ctx.append(id, 'attached', { parent_id: parent });
|
|
@@ -499,7 +544,7 @@ async function cmdSetJudge(args, ctx) {
|
|
|
499
544
|
return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
|
|
500
545
|
}
|
|
501
546
|
ctx.append(view.id, 'judge_set', { judge, from: view.judge });
|
|
502
|
-
ctx.append(view.id, 'judge_ran',
|
|
547
|
+
ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
|
|
503
548
|
const after = getCampaign(ctx.store, view.id);
|
|
504
549
|
ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
|
|
505
550
|
return 0;
|
|
@@ -519,6 +564,56 @@ async function cmdJudge(args, ctx) {
|
|
|
519
564
|
return reading.passed ? 0 : 1;
|
|
520
565
|
}
|
|
521
566
|
|
|
567
|
+
async function cmdRsi(args, ctx) {
|
|
568
|
+
const id = positionals(args)[1] || null;
|
|
569
|
+
if (id) {
|
|
570
|
+
const { error } = requireCampaign(ctx, id);
|
|
571
|
+
if (error !== undefined) return error;
|
|
572
|
+
}
|
|
573
|
+
const {
|
|
574
|
+
formatNextCampaigns,
|
|
575
|
+
formatOvernightRsi,
|
|
576
|
+
formatRsiReport,
|
|
577
|
+
runOvernightRsi,
|
|
578
|
+
selectNextCampaigns,
|
|
579
|
+
summarizeStoreRsi,
|
|
580
|
+
writeRsiSnapshot,
|
|
581
|
+
} = require('../lib/campaign-rsi');
|
|
582
|
+
if (args.includes('--loop')) {
|
|
583
|
+
const rounds = Number(readFlag(args, '--rounds', '1'));
|
|
584
|
+
if (!Number.isSafeInteger(rounds) || rounds < 1) return ctx.fail('--rounds must be a positive whole number');
|
|
585
|
+
const res = await runOvernightRsi(process.cwd(), {
|
|
586
|
+
store: ctx.store,
|
|
587
|
+
rounds,
|
|
588
|
+
campaignId: id,
|
|
589
|
+
dryRun: args.includes('--dry-run'),
|
|
590
|
+
stepCampaign: async (campaignId, selected, prepare) => {
|
|
591
|
+
let step = null;
|
|
592
|
+
const code = await cmdLoop(['step', campaignId, ...args.slice(1)], {
|
|
593
|
+
...ctx,
|
|
594
|
+
out: (text, data) => { step = data; },
|
|
595
|
+
fail: (error, code = 2) => { step = { ok: false, error }; return code; },
|
|
596
|
+
}, 'step', prepare);
|
|
597
|
+
// Keep receipts small: campaign state is captured by the after reading.
|
|
598
|
+
const { campaign, next, ...result } = step || {};
|
|
599
|
+
return { ...result, ok: code === 0 && result.ok !== false };
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
ctx.out(formatOvernightRsi(res), { ok: true, action: 'campaign_rsi_loop', ...res });
|
|
603
|
+
return res.ok ? 0 : 1;
|
|
604
|
+
}
|
|
605
|
+
const rsi = args.includes('--no-write')
|
|
606
|
+
? summarizeStoreRsi(ctx.store, id)
|
|
607
|
+
: writeRsiSnapshot(process.cwd(), ctx.store, id);
|
|
608
|
+
if (args.includes('--next')) {
|
|
609
|
+
const nextList = selectNextCampaigns(ctx.store, rsi);
|
|
610
|
+
ctx.out(formatNextCampaigns(nextList), { ok: true, action: 'campaign_rsi_next', next_campaigns: nextList, rsi });
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
ctx.out(formatRsiReport(rsi), { ok: true, action: 'campaign_rsi', rsi });
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
522
617
|
// Exit 1 when a protected path changed since start, naming each one.
|
|
523
618
|
function cmdGuard(args, ctx) {
|
|
524
619
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
@@ -636,7 +731,7 @@ const cmdEvent = (args, ctx) => {
|
|
|
636
731
|
return 0;
|
|
637
732
|
};
|
|
638
733
|
|
|
639
|
-
async function cmdLoop(args, ctx, verb) {
|
|
734
|
+
async function cmdLoop(args, ctx, verb, prepare = null) {
|
|
640
735
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
641
736
|
if (error !== undefined) return error;
|
|
642
737
|
// One driver per campaign: a second loop would plan and start missions
|
|
@@ -651,9 +746,13 @@ async function cmdLoop(args, ctx, verb) {
|
|
|
651
746
|
process.once('exit', lock.release);
|
|
652
747
|
}
|
|
653
748
|
try {
|
|
749
|
+
if (prepare) prepare();
|
|
654
750
|
return await driveLoop(args, ctx, verb, view);
|
|
655
751
|
} finally {
|
|
656
|
-
if (lock)
|
|
752
|
+
if (lock) {
|
|
753
|
+
process.removeListener('exit', lock.release);
|
|
754
|
+
lock.release();
|
|
755
|
+
}
|
|
657
756
|
}
|
|
658
757
|
}
|
|
659
758
|
|
|
@@ -706,7 +805,7 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
706
805
|
const result = await loop.step(view.id);
|
|
707
806
|
const after = getCampaign(ctx.store, view.id);
|
|
708
807
|
ctx.out(`${result.stop ? `stopped: ${result.reason}` : `did: ${result.did}`}\n${renderShow(after)}`, { ok: true, ...result, campaign: after, next: decide(after), sync_warnings: syncWarnings });
|
|
709
|
-
return 0;
|
|
808
|
+
return result.ok === false ? 1 : 0;
|
|
710
809
|
}
|
|
711
810
|
const work = readFlag(args, '--work', '');
|
|
712
811
|
const pause = readFlag(args, '--break', '');
|
|
@@ -730,11 +829,116 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
730
829
|
return 0;
|
|
731
830
|
}
|
|
732
831
|
|
|
832
|
+
// atris campaign tick: one bounded push on the next campaign in this workspace that has
|
|
833
|
+
// work to do. The overnight heartbeat (atris pulse tick) calls it, so campaigns stacked
|
|
834
|
+
// before bed keep moving all night: when one finishes, the next tick picks the next.
|
|
835
|
+
// Skipped: done, paused, out of money, waiting on a person, or driven by another run.
|
|
836
|
+
async function cmdTick(args, ctx) {
|
|
837
|
+
const { listCampaigns } = require('../lib/campaign-store');
|
|
838
|
+
const { liveHolder } = require('../lib/campaign-run-lock');
|
|
839
|
+
const maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
|
|
840
|
+
const skipped = [];
|
|
841
|
+
let pick = null;
|
|
842
|
+
for (const view of listCampaigns(ctx.store)) {
|
|
843
|
+
if (view.error) { skipped.push({ id: view.id, why: `unreadable: ${view.error}` }); continue; }
|
|
844
|
+
const move = decide(view);
|
|
845
|
+
if (move.action === 'none') continue;
|
|
846
|
+
if (move.action === 'wait' && move.reason === 'open asks') { skipped.push({ id: view.id, why: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` }); continue; }
|
|
847
|
+
if (move.action === 'wait' && move.reason === 'budget') { skipped.push({ id: view.id, why: 'waiting for budget' }); continue; }
|
|
848
|
+
// A campaign sleeping until its next check is skipped, so the next one with work gets the push.
|
|
849
|
+
if (view.next_move && view.next_move.action === 'wait' && Date.now() < view.next_move.wake_at) { skipped.push({ id: view.id, why: `next check at ${new Date(view.next_move.wake_at).toISOString()}` }); continue; }
|
|
850
|
+
const holder = ctx.campaignHome ? liveHolder(ctx.campaignHome, view.id) : null;
|
|
851
|
+
if (holder) { skipped.push({ id: view.id, why: `another run drives it (pid ${holder.pid})` }); continue; }
|
|
852
|
+
pick = view;
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
if (!pick) {
|
|
856
|
+
const why = skipped.length ? skipped.map((s) => `${s.id}: ${s.why}`).join('; ') : 'every campaign here is done, paused, or out of money';
|
|
857
|
+
ctx.out(`nothing to advance: ${why}`, { ok: true, picked: null, skipped });
|
|
858
|
+
return 0;
|
|
859
|
+
}
|
|
860
|
+
if (!ctx.asJson) console.log(`advancing ${pick.id} (up to ${maxSteps} steps)${skipped.length ? `; skipped ${skipped.map((s) => s.id).join(', ')}` : ''}`);
|
|
861
|
+
// One mission at a time unless asked: nobody is watching overnight, and missions
|
|
862
|
+
// in one folder can write the same files.
|
|
863
|
+
const passthrough = ['--parallel', readFlag(args, '--parallel', '') || '1'];
|
|
864
|
+
for (const flag of ['--runner', '--max-ticks', '--mission-minutes']) {
|
|
865
|
+
const value = readFlag(args, flag, '');
|
|
866
|
+
if (value) passthrough.push(flag, value);
|
|
867
|
+
}
|
|
868
|
+
return cmdLoop(['run', pick.id, '--max-steps', String(maxSteps), ...passthrough, ...(ctx.asJson ? ['--json'] : [])], ctx, 'run');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// One step for every due campaign: a saved wait whose time has come, or work
|
|
872
|
+
// with no wait saved. Finished, paused, and broke campaigns are skipped, and so
|
|
873
|
+
// is a wait not yet due. Exits 0 when a step fails, so a timer keeps firing;
|
|
874
|
+
// each campaign's result is in the summary.
|
|
875
|
+
async function cmdWake(args, ctx) {
|
|
876
|
+
const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
|
|
877
|
+
const flags = args.slice(args.indexOf('wake') + 1);
|
|
878
|
+
const woke = [];
|
|
879
|
+
const skipped = [];
|
|
880
|
+
for (const view of listCampaigns(ctx.store)) {
|
|
881
|
+
if (view.error) { skipped.push({ campaign_id: view.id, reason: `cannot be read: ${view.error}` }); continue; }
|
|
882
|
+
const move = decide(view);
|
|
883
|
+
if (move.action === 'none') { skipped.push({ campaign_id: view.id, reason: move.reason }); continue; }
|
|
884
|
+
const saved = view.next_move;
|
|
885
|
+
if (saved && saved.action === 'wait' && now() < saved.wake_at) {
|
|
886
|
+
skipped.push({ campaign_id: view.id, reason: `next check at ${new Date(saved.wake_at).toISOString()}` });
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
let step = null;
|
|
890
|
+
let code;
|
|
891
|
+
try {
|
|
892
|
+
code = await cmdLoop(['step', view.id, ...flags], {
|
|
893
|
+
...ctx,
|
|
894
|
+
out: (text, data) => { step = data; },
|
|
895
|
+
fail: (error, failCode = 2) => { step = { ok: false, error }; return failCode; },
|
|
896
|
+
}, 'step');
|
|
897
|
+
} catch (err) {
|
|
898
|
+
code = 1;
|
|
899
|
+
step = { ok: false, error: err.message };
|
|
900
|
+
}
|
|
901
|
+
const { campaign, next, ...result } = step || {};
|
|
902
|
+
woke.push({ campaign_id: view.id, ...result, ok: code === 0 && result.ok !== false });
|
|
903
|
+
}
|
|
904
|
+
const lines = [
|
|
905
|
+
...woke.map((w) => `${w.campaign_id} ${w.ok ? (w.stop ? `stopped: ${w.reason}` : `did: ${w.did}`) : `failed: ${w.error || w.reason}`}`),
|
|
906
|
+
...skipped.map((s) => `${s.campaign_id} skipped: ${s.reason}`),
|
|
907
|
+
];
|
|
908
|
+
const head = `woke ${woke.length} campaign${woke.length === 1 ? '' : 's'}, skipped ${skipped.length}`;
|
|
909
|
+
ctx.out([head, ...lines].join('\n'), { ok: true, action: 'campaign_wake', at: new Date(now()).toISOString(), woke, skipped });
|
|
910
|
+
return 0;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// A one-minute timer that runs `atris campaign wake` for this checkout.
|
|
914
|
+
function cmdKeep(args, ctx) {
|
|
915
|
+
const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
|
|
916
|
+
const install = args.includes('--install');
|
|
917
|
+
if (install === args.includes('--remove')) return ctx.fail('say which: atris campaign keep --install|--remove [--dry-run]');
|
|
918
|
+
const keepDeps = ctx.keepDeps || {};
|
|
919
|
+
const plan = planKeep({ root: process.cwd(), home: ctx.campaignHome, atrisBin: path.resolve(__dirname, '..', 'bin', 'atris.js'), ...(keepDeps.plan || {}) });
|
|
920
|
+
const action = install ? 'keep_install' : 'keep_remove';
|
|
921
|
+
const where = plan.kind === 'launchd' ? plan.file : 'your crontab';
|
|
922
|
+
const describe = `a ${plan.kind} timer that runs every minute:\n ${plan.command}\n in ${plan.root}\n at ${where}\n log ${plan.log_path}`;
|
|
923
|
+
if (args.includes('--dry-run')) {
|
|
924
|
+
ctx.out(`would ${install ? 'install' : 'remove'} ${describe}`, { ok: true, action, dry_run: true, plan });
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
927
|
+
const result = (install ? installKeep : removeKeep)(plan, keepDeps);
|
|
928
|
+
if (!result.ok) return ctx.fail(result.error, 1);
|
|
929
|
+
const done = install ? `installed ${describe}` : (result.removed ? `removed the timer at ${where}` : 'no timer was installed for this checkout');
|
|
930
|
+
ctx.out(done, { ok: true, action, ...result, plan });
|
|
931
|
+
return 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
733
934
|
const VERBS = {
|
|
935
|
+
wake: cmdWake, keep: cmdKeep,
|
|
734
936
|
start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
|
|
735
937
|
pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
|
|
736
938
|
ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
|
|
939
|
+
rsi: cmdRsi, learn: cmdRsi,
|
|
737
940
|
run: (args, ctx) => cmdLoop(args, ctx, 'run'),
|
|
941
|
+
tick: cmdTick,
|
|
738
942
|
step: (args, ctx) => cmdLoop(args, ctx, 'step'),
|
|
739
943
|
propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
|
|
740
944
|
// atris campaign new "<goal>": the owner's engine drafts a judge that fails today (lib/campaign-new.js).
|
package/commands/improve.js
CHANGED
|
@@ -528,7 +528,7 @@ function collectImproveVitals(options = {}, deps = {}) {
|
|
|
528
528
|
};
|
|
529
529
|
|
|
530
530
|
// The guarantee gauge: agent landings vs human fixes over the last 14 days.
|
|
531
|
-
//
|
|
531
|
+
// The lookback filters results while traversal can visit older ancestors.
|
|
532
532
|
// No git history (not a repo, git missing) means the line is omitted silently.
|
|
533
533
|
let guarantee = null;
|
|
534
534
|
try {
|
|
@@ -1313,7 +1313,8 @@ function collectRevisionSignals(root, options = {}) {
|
|
|
1313
1313
|
|
|
1314
1314
|
let raw = '';
|
|
1315
1315
|
try {
|
|
1316
|
-
|
|
1316
|
+
// Filter dates without pruning ancestors behind an older-dated descendant.
|
|
1317
|
+
raw = gitLines(root, ['log', `--since-as-filter=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
|
|
1317
1318
|
} catch (e) {
|
|
1318
1319
|
// a repo with no commits yet exits non-zero on `git log`; that is the
|
|
1319
1320
|
// empty-history case, not an error. anything else (not a repo) rethrows.
|
package/commands/mission.js
CHANGED
|
@@ -64,6 +64,7 @@ const {
|
|
|
64
64
|
renderMorningCardRow,
|
|
65
65
|
} = require('../lib/receipt-block');
|
|
66
66
|
const { findCachedMissionStepReceipt } = require('../lib/receipt-evidence');
|
|
67
|
+
const { captureCodeBaseline, collectCodeProvenance } = require('../lib/mission-code-provenance');
|
|
67
68
|
const {
|
|
68
69
|
pruneRuns,
|
|
69
70
|
runsPruneLines,
|
|
@@ -4402,6 +4403,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
|
|
|
4402
4403
|
nextMission.next_action = `decide next mission, then run: ${nextPlan.command}`;
|
|
4403
4404
|
nextMission.next_action_preview = nextPlan.preview;
|
|
4404
4405
|
|
|
4406
|
+
const codeBaseline = captureCodeBaseline(root);
|
|
4405
4407
|
ensureMemberMissionFile(nextMission.owner, root, nextMission.objective);
|
|
4406
4408
|
const { mission: saved } = saveMission(assignMissionNumber(nextMission, root), root, 'mission_continuation_started', {
|
|
4407
4409
|
parent_mission_id: parent.id,
|
|
@@ -4409,7 +4411,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
|
|
|
4409
4411
|
proof: proof || null,
|
|
4410
4412
|
});
|
|
4411
4413
|
if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(root, saved, 0);
|
|
4412
|
-
const worktreeBaseline = captureMissionWorktreeBaseline(saved, root);
|
|
4414
|
+
const worktreeBaseline = captureMissionWorktreeBaseline(saved, root, codeBaseline);
|
|
4413
4415
|
const seededAt = stampIso();
|
|
4414
4416
|
const { mission: updatedParent } = saveMission({
|
|
4415
4417
|
...parent,
|
|
@@ -4656,6 +4658,7 @@ function startMission(args, options = {}) {
|
|
|
4656
4658
|
if (nodeModulesSource) mission.node_modules_source = nodeModulesSource;
|
|
4657
4659
|
process.chdir(created.path);
|
|
4658
4660
|
}
|
|
4661
|
+
const codeBaseline = captureCodeBaseline(process.cwd());
|
|
4659
4662
|
if (mission.xp_task_enabled) {
|
|
4660
4663
|
const xpTask = createMissionXpTask(mission, process.cwd(), asJson);
|
|
4661
4664
|
mission.xp_task = xpTask;
|
|
@@ -4696,7 +4699,7 @@ function startMission(args, options = {}) {
|
|
|
4696
4699
|
lane: saved.lane,
|
|
4697
4700
|
verifier: saved.verifier,
|
|
4698
4701
|
});
|
|
4699
|
-
const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
|
|
4702
|
+
const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
|
|
4700
4703
|
const nextTickCommand = `atris mission tick ${saved.id}${saved.verifier ? ' --verify' : ''}`;
|
|
4701
4704
|
const payload = {
|
|
4702
4705
|
ok: true,
|
|
@@ -4738,6 +4741,7 @@ function startMission(args, options = {}) {
|
|
|
4738
4741
|
|
|
4739
4742
|
async function startMissionFromRunObjective(objective, args) {
|
|
4740
4743
|
const asJson = wantsJson(args);
|
|
4744
|
+
const codeBaseline = captureCodeBaseline(process.cwd());
|
|
4741
4745
|
const rawObjective = String(objective || '').trim();
|
|
4742
4746
|
const landRun = hasFlag(args, '--land');
|
|
4743
4747
|
const dryRun = hasFlag(args, '--dry-run');
|
|
@@ -4939,7 +4943,7 @@ async function startMissionFromRunObjective(objective, args) {
|
|
|
4939
4943
|
lane: saved.lane,
|
|
4940
4944
|
verifier: saved.verifier,
|
|
4941
4945
|
});
|
|
4942
|
-
const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
|
|
4946
|
+
const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
|
|
4943
4947
|
const completedContinuationGoal = completeActiveContinuationForStartedMission(saved, process.cwd());
|
|
4944
4948
|
const nativeGoalOptions = codexNativeGoalOptionsFromArgs(args);
|
|
4945
4949
|
const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
|
|
@@ -6629,6 +6633,7 @@ function writeReceipt(mission, result, root = process.cwd()) {
|
|
|
6629
6633
|
tree_hash: treeHashFor(root),
|
|
6630
6634
|
clean: codeState.clean,
|
|
6631
6635
|
git_tree: codeState.clean ? codeState.tree : null,
|
|
6636
|
+
code_provenance: collectCodeProvenance(root, loadMissionWorktreeBaseline(mission.id, root)?.code),
|
|
6632
6637
|
mission_id: mission.id,
|
|
6633
6638
|
objective: mission.objective,
|
|
6634
6639
|
owner: mission.owner,
|
|
@@ -6864,7 +6869,7 @@ function missionBaselinePath(missionId, root = process.cwd()) {
|
|
|
6864
6869
|
// Stored outside missions.jsonl because that log re-appends the full mission
|
|
6865
6870
|
// record on every save. Captured after start bookkeeping so the mission's own
|
|
6866
6871
|
// state writes land inside the baseline, not in new_since_baseline.
|
|
6867
|
-
function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
|
|
6872
|
+
function captureMissionWorktreeBaseline(mission, root = process.cwd(), code = captureCodeBaseline(root)) {
|
|
6868
6873
|
const snapshot = gitWorktreeSnapshot(root);
|
|
6869
6874
|
if (!snapshot.available) return null;
|
|
6870
6875
|
const baselineFile = missionBaselinePath(mission.id, root);
|
|
@@ -6880,6 +6885,7 @@ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
|
|
|
6880
6885
|
dirty_count: snapshot.dirty_count,
|
|
6881
6886
|
dirty_hash: snapshot.dirty_hash,
|
|
6882
6887
|
paths: Array.from(paths).sort(),
|
|
6888
|
+
code,
|
|
6883
6889
|
};
|
|
6884
6890
|
writeJson(baselineFile, baseline);
|
|
6885
6891
|
return baseline;
|
|
@@ -6989,18 +6995,34 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
|
|
|
6989
6995
|
// before the tick fires. Same tag-plus-text routing the task lane uses
|
|
6990
6996
|
// (CLI-1189). An operator override is explicit: protected_lane_ack in the
|
|
6991
6997
|
// mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
|
|
6992
|
-
function missionProtectedLaneHold(mission) {
|
|
6998
|
+
function missionProtectedLaneHold(mission, root = process.cwd()) {
|
|
6993
6999
|
if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
|
|
6994
7000
|
if (mission?.metadata?.protected_lane_ack) return null;
|
|
6995
7001
|
const { DENIED_TAGS } = require('../lib/fleet');
|
|
6996
7002
|
const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
|
|
6997
7003
|
const lane = String(mission?.lane || '').toLowerCase();
|
|
6998
|
-
if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
|
|
6999
7004
|
const probe = { title: mission?.objective, objective: mission?.stop_condition, metadata: mission?.metadata || {} };
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
if (
|
|
7003
|
-
|
|
7005
|
+
let hold = null;
|
|
7006
|
+
if (DENIED_TAGS.includes(lane)) hold = { pause_reason: `protected-lane-${lane}`, lane };
|
|
7007
|
+
else if (declaredProtectedLane(probe)) hold = { pause_reason: 'protected-lane-declared', lane: 'declared' };
|
|
7008
|
+
else {
|
|
7009
|
+
const sniffed = sniffedProtectedLane(probe);
|
|
7010
|
+
if (sniffed) hold = { pause_reason: `protected-lane-${sniffed.lane}`, lane: sniffed.lane };
|
|
7011
|
+
}
|
|
7012
|
+
// Inside a campaign, a person may have allowed this lane by answering the
|
|
7013
|
+
// campaign's ask. Only a person's answer closes an ask, so it counts as the ack.
|
|
7014
|
+
if (hold && mission?.campaign_id && campaignApprovedLane(root, mission.campaign_id, hold.lane)) return null;
|
|
7015
|
+
return hold ? { pause_reason: hold.pause_reason } : null;
|
|
7016
|
+
}
|
|
7017
|
+
|
|
7018
|
+
function campaignApprovedLane(root, campaignId, lane) {
|
|
7019
|
+
try {
|
|
7020
|
+
const { openCampaignStore } = require('../lib/campaign-store');
|
|
7021
|
+
const { approvedLanes } = require('../lib/campaign-lanes');
|
|
7022
|
+
return approvedLanes(openCampaignStore(root).read(campaignId)).has(lane);
|
|
7023
|
+
} catch {
|
|
7024
|
+
return false;
|
|
7025
|
+
}
|
|
7004
7026
|
}
|
|
7005
7027
|
|
|
7006
7028
|
function runnerUsesCallerSession(runner) {
|
|
@@ -10342,7 +10364,7 @@ async function executeMissionRunTicksPhase(context) {
|
|
|
10342
10364
|
if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
|
|
10343
10365
|
if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
|
|
10344
10366
|
if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
|
|
10345
|
-
const protectedHold = missionProtectedLaneHold(mission);
|
|
10367
|
+
const protectedHold = missionProtectedLaneHold(mission, cwd);
|
|
10346
10368
|
if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
|
|
10347
10369
|
|
|
10348
10370
|
const tickIdx = Number(mission.last_tick_index || 0) + 1;
|