atris 3.71.2 → 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/bin/atris.js +1 -1
- package/commands/campaign.js +185 -6
- package/commands/improve.js +3 -2
- package/commands/mission.js +10 -4
- package/commands/pulse.js +89 -5
- package/commands/worktree.js +24 -2
- package/lib/campaign-cadence.js +39 -10
- package/lib/campaign-core.js +61 -7
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +137 -27
- 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
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
|
@@ -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');
|
|
@@ -251,7 +257,7 @@ function makeContext(args, deps) {
|
|
|
251
257
|
const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
|
|
252
258
|
// A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
|
|
253
259
|
const folder = deps.campaignFolder || ((id) => path.join(holdoutRoot, 'campaigns', id));
|
|
254
|
-
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 || {} };
|
|
255
261
|
}
|
|
256
262
|
|
|
257
263
|
function requireCampaign(ctx, id) {
|
|
@@ -301,7 +307,21 @@ function renderShow(view) {
|
|
|
301
307
|
row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
|
|
302
308
|
if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
|
|
303
309
|
if (view.parent) row('parent', view.parent);
|
|
304
|
-
|
|
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');
|
|
305
325
|
row('missions', view.missions.length ? '' : 'none');
|
|
306
326
|
for (const m of view.missions) {
|
|
307
327
|
const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
|
|
@@ -524,7 +544,7 @@ async function cmdSetJudge(args, ctx) {
|
|
|
524
544
|
return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
|
|
525
545
|
}
|
|
526
546
|
ctx.append(view.id, 'judge_set', { judge, from: view.judge });
|
|
527
|
-
ctx.append(view.id, 'judge_ran',
|
|
547
|
+
ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
|
|
528
548
|
const after = getCampaign(ctx.store, view.id);
|
|
529
549
|
ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
|
|
530
550
|
return 0;
|
|
@@ -544,6 +564,56 @@ async function cmdJudge(args, ctx) {
|
|
|
544
564
|
return reading.passed ? 0 : 1;
|
|
545
565
|
}
|
|
546
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
|
+
|
|
547
617
|
// Exit 1 when a protected path changed since start, naming each one.
|
|
548
618
|
function cmdGuard(args, ctx) {
|
|
549
619
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
@@ -661,7 +731,7 @@ const cmdEvent = (args, ctx) => {
|
|
|
661
731
|
return 0;
|
|
662
732
|
};
|
|
663
733
|
|
|
664
|
-
async function cmdLoop(args, ctx, verb) {
|
|
734
|
+
async function cmdLoop(args, ctx, verb, prepare = null) {
|
|
665
735
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
666
736
|
if (error !== undefined) return error;
|
|
667
737
|
// One driver per campaign: a second loop would plan and start missions
|
|
@@ -676,9 +746,13 @@ async function cmdLoop(args, ctx, verb) {
|
|
|
676
746
|
process.once('exit', lock.release);
|
|
677
747
|
}
|
|
678
748
|
try {
|
|
749
|
+
if (prepare) prepare();
|
|
679
750
|
return await driveLoop(args, ctx, verb, view);
|
|
680
751
|
} finally {
|
|
681
|
-
if (lock)
|
|
752
|
+
if (lock) {
|
|
753
|
+
process.removeListener('exit', lock.release);
|
|
754
|
+
lock.release();
|
|
755
|
+
}
|
|
682
756
|
}
|
|
683
757
|
}
|
|
684
758
|
|
|
@@ -731,7 +805,7 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
731
805
|
const result = await loop.step(view.id);
|
|
732
806
|
const after = getCampaign(ctx.store, view.id);
|
|
733
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 });
|
|
734
|
-
return 0;
|
|
808
|
+
return result.ok === false ? 1 : 0;
|
|
735
809
|
}
|
|
736
810
|
const work = readFlag(args, '--work', '');
|
|
737
811
|
const pause = readFlag(args, '--break', '');
|
|
@@ -755,11 +829,116 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
755
829
|
return 0;
|
|
756
830
|
}
|
|
757
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
|
+
|
|
758
934
|
const VERBS = {
|
|
935
|
+
wake: cmdWake, keep: cmdKeep,
|
|
759
936
|
start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
|
|
760
937
|
pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
|
|
761
938
|
ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
|
|
939
|
+
rsi: cmdRsi, learn: cmdRsi,
|
|
762
940
|
run: (args, ctx) => cmdLoop(args, ctx, 'run'),
|
|
941
|
+
tick: cmdTick,
|
|
763
942
|
step: (args, ctx) => cmdLoop(args, ctx, 'step'),
|
|
764
943
|
propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
|
|
765
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;
|
package/commands/pulse.js
CHANGED
|
@@ -53,6 +53,9 @@ Options:
|
|
|
53
53
|
--cadence "<cron>" Cron cadence for install
|
|
54
54
|
--days <n> Auto-expire installed heartbeat after n days
|
|
55
55
|
--hours <n> Auto-expire installed heartbeat after n hours
|
|
56
|
+
--no-keep-awake Install without holding the Mac awake for the window (macOS)
|
|
57
|
+
--no-autopilot install/tick: never fall back to autopilot (only campaigns and due missions)
|
|
58
|
+
--no-campaigns tick: skip pushing campaigns (atris campaign tick)
|
|
56
59
|
--model <id> Runner model alias/id for installed heartbeat
|
|
57
60
|
--runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
|
|
58
61
|
--runner-bin <path> Runner binary for installed heartbeat
|
|
@@ -128,9 +131,43 @@ function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
|
|
|
128
131
|
};
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
+
// Campaigns first: one bounded push on the next campaign here that has work
|
|
135
|
+
// (atris campaign tick). A campaign mission can outlast a mission tick, so it gets
|
|
136
|
+
// up to 50 minutes; the campaign run lock keeps two drivers off one campaign.
|
|
137
|
+
const CAMPAIGN_TICK_MS = 50 * 60 * 1000;
|
|
138
|
+
function runCampaignEngine(root, { timeoutMs = CAMPAIGN_TICK_MS } = {}) {
|
|
139
|
+
const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
140
|
+
const result = spawnSync(process.execPath, [cliPath, 'campaign', 'tick', '--json'], {
|
|
141
|
+
cwd: root,
|
|
142
|
+
encoding: 'utf8',
|
|
143
|
+
timeout: timeoutMs,
|
|
144
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
|
|
145
|
+
});
|
|
146
|
+
let payload = null;
|
|
147
|
+
try {
|
|
148
|
+
payload = JSON.parse(result.stdout || '{}');
|
|
149
|
+
} catch {}
|
|
150
|
+
const idle = Boolean(payload && payload.picked === null);
|
|
151
|
+
return {
|
|
152
|
+
actor: 'campaign_tick',
|
|
153
|
+
ok: result.status === 0,
|
|
154
|
+
reason: idle ? 'no_campaign_work' : (payload && payload.reason) || (result.status === 0 ? 'advanced' : 'error'),
|
|
155
|
+
idle,
|
|
156
|
+
status: result.status,
|
|
157
|
+
payload,
|
|
158
|
+
stdout: String(result.stdout || '').slice(-2000),
|
|
159
|
+
stderr: String(result.stderr || '').slice(-2000),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The full heartbeat composition: push a campaign that has work, else continue a
|
|
164
|
+
// due mission, else author+pursue a new goal via autopilot. This is the /loop
|
|
165
|
+
// skill's logic, encoded for OS cron.
|
|
166
|
+
function runEngine(root, { noClaude = false, autopilotFallback = true, campaigns = true, timeoutMs = 600000 } = {}) {
|
|
167
|
+
if (campaigns && !noClaude) {
|
|
168
|
+
const campaign = runCampaignEngine(root);
|
|
169
|
+
if (!campaign.idle) return campaign;
|
|
170
|
+
}
|
|
134
171
|
const mission = runMissionEngine(root, { noClaude, timeoutMs });
|
|
135
172
|
if (pulse.shouldFallbackToAutopilot({ missionReason: mission.reason, autopilotFallback, noClaude })) {
|
|
136
173
|
const ap = runAutopilotTick(root, { timeoutMs });
|
|
@@ -284,7 +321,7 @@ function tickCommand(args, root = process.cwd()) {
|
|
|
284
321
|
// filesystem snapshot when git is absent.
|
|
285
322
|
const isRepo = fs.existsSync(path.join(root, '.git'));
|
|
286
323
|
const before = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
|
|
287
|
-
engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
|
|
324
|
+
engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot'), campaigns: !hasFlag(args, '--no-campaigns') });
|
|
288
325
|
const after = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
|
|
289
326
|
// This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
|
|
290
327
|
// Pre-existing dirt is excluded so reward isn't re-credited every tick.
|
|
@@ -315,7 +352,9 @@ function tickCommand(args, root = process.cwd()) {
|
|
|
315
352
|
const changedTail = committed
|
|
316
353
|
? ' - committed'
|
|
317
354
|
: (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
|
|
318
|
-
const what = engine.actor === '
|
|
355
|
+
const what = engine.actor === 'campaign_tick'
|
|
356
|
+
? `campaign ${(engine.payload && engine.payload.campaign && engine.payload.campaign.id) || ''} ${engine.reason}${changedTail}`.replace(/\s+/g, ' ')
|
|
357
|
+
: engine.actor === 'autopilot'
|
|
319
358
|
? `autopilot authored/advanced a goal${changedTail}`
|
|
320
359
|
: engine.reason === 'no_due_mission'
|
|
321
360
|
? 'no due mission; heartbeat alive (no-op)'
|
|
@@ -677,6 +716,8 @@ function installCommand(args, root = process.cwd(), options = {}) {
|
|
|
677
716
|
pathDirs,
|
|
678
717
|
launchLabel: label,
|
|
679
718
|
launchPlistPath: plistPath,
|
|
719
|
+
// Baked into every tick: a night of campaigns only, no invented goals.
|
|
720
|
+
tickFlags: ['--no-autopilot', '--no-campaigns'].filter((flag) => hasFlag(args, flag)),
|
|
680
721
|
});
|
|
681
722
|
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
682
723
|
|
|
@@ -695,6 +736,10 @@ function installCommand(args, root = process.cwd(), options = {}) {
|
|
|
695
736
|
const apply = commandSpawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8', timeout: 10000 });
|
|
696
737
|
const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
|
|
697
738
|
const cadenceMapping = pulse.launchIntervalFromCadence({ cron });
|
|
739
|
+
// Tests pass their own spawnSync and never get a real caffeinate unless they ask.
|
|
740
|
+
const wantsKeepAwake = apply.status === 0 && process.platform === 'darwin' && !hasFlag(args, '--no-keep-awake')
|
|
741
|
+
&& (!options.spawnSync || options.holdKeepAwake);
|
|
742
|
+
const keepAwake = wantsKeepAwake ? (options.holdKeepAwake || holdKeepAwake)(slot.stateHome, expiry.seconds) : null;
|
|
698
743
|
|
|
699
744
|
const out = {
|
|
700
745
|
ok: apply.status === 0,
|
|
@@ -718,6 +763,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
|
|
|
718
763
|
runner_bin: runnerBin || null,
|
|
719
764
|
runner_template_configured: Boolean(runnerCommandTemplate),
|
|
720
765
|
expiry_breadcrumb_cleared: expiryBreadcrumbCleared,
|
|
766
|
+
keep_awake: keepAwake,
|
|
721
767
|
};
|
|
722
768
|
if (!asJson) {
|
|
723
769
|
if (apply.status === 0) {
|
|
@@ -728,6 +774,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
|
|
|
728
774
|
`script: ${scriptPath}`,
|
|
729
775
|
cadenceMapping.fallback ? `cadence '${cron}' is not directly mappable; using 3600 seconds.` : '',
|
|
730
776
|
`auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
|
|
777
|
+
...(keepAwake ? [`keeps this Mac awake until then (lid open); atris pulse uninstall lets it sleep.`] : []),
|
|
731
778
|
].filter(Boolean).join('\n') + '\n');
|
|
732
779
|
} else {
|
|
733
780
|
writeOutput(`pulse install failed to bootstrap ${label}: ${apply.stderr || apply.status}\nplist: ${plistPath}\n`);
|
|
@@ -736,6 +783,40 @@ function installCommand(args, root = process.cwd(), options = {}) {
|
|
|
736
783
|
return emit(out, asJson);
|
|
737
784
|
}
|
|
738
785
|
|
|
786
|
+
// The heartbeat only ticks while the Mac is awake. On macOS an overnight install
|
|
787
|
+
// holds a no-idle-sleep assertion (caffeinate -i) for exactly the heartbeat's
|
|
788
|
+
// window, so a Mac on its own power settings does not sleep through the night.
|
|
789
|
+
// Closing the lid still sleeps. Uninstall lets it go. --no-keep-awake skips it.
|
|
790
|
+
function keepAwakeFile(stateHome) {
|
|
791
|
+
return path.join(stateHome, 'keep-awake.pid');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function releaseKeepAwake(stateHome) {
|
|
795
|
+
const file = keepAwakeFile(stateHome);
|
|
796
|
+
let pid = 0;
|
|
797
|
+
try { pid = Number(fs.readFileSync(file, 'utf8').trim()); } catch { return false; }
|
|
798
|
+
fs.rmSync(file, { force: true });
|
|
799
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
800
|
+
try {
|
|
801
|
+
const name = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
|
|
802
|
+
if (!/caffeinate$/.test(name)) return false;
|
|
803
|
+
process.kill(pid, 'SIGTERM');
|
|
804
|
+
return true;
|
|
805
|
+
} catch {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function holdKeepAwake(stateHome, seconds) {
|
|
811
|
+
releaseKeepAwake(stateHome);
|
|
812
|
+
const { spawn } = require('child_process');
|
|
813
|
+
const child = spawn('caffeinate', ['-i', '-t', String(Math.max(60, Math.floor(seconds)))], { detached: true, stdio: 'ignore' });
|
|
814
|
+
child.unref();
|
|
815
|
+
fs.mkdirSync(stateHome, { recursive: true });
|
|
816
|
+
fs.writeFileSync(keepAwakeFile(stateHome), `${child.pid}\n`);
|
|
817
|
+
return { pid: child.pid, seconds: Math.floor(seconds) };
|
|
818
|
+
}
|
|
819
|
+
|
|
739
820
|
function uninstallCommand(args, root = process.cwd(), options = {}) {
|
|
740
821
|
const asJson = wantsJson(args);
|
|
741
822
|
const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
|
|
@@ -745,6 +826,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
|
|
|
745
826
|
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
746
827
|
const uid = options.uid != null ? options.uid : process.getuid();
|
|
747
828
|
commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
|
|
829
|
+
const keepAwakeReleased = (options.releaseKeepAwake || releaseKeepAwake)(slot.stateHome || slot.activeStateHome);
|
|
748
830
|
const plistHad = fs.existsSync(plistPath);
|
|
749
831
|
fs.rmSync(plistPath, { force: true });
|
|
750
832
|
const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
@@ -757,6 +839,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
|
|
|
757
839
|
ok: apply.status === 0,
|
|
758
840
|
action: 'pulse_uninstall',
|
|
759
841
|
removed: plistHad || had,
|
|
842
|
+
keep_awake_released: keepAwakeReleased,
|
|
760
843
|
label,
|
|
761
844
|
plist_path: plistPath,
|
|
762
845
|
removed_legacy_crontab: had,
|
|
@@ -818,5 +901,6 @@ module.exports = {
|
|
|
818
901
|
pulseExpiryBreadcrumbPath,
|
|
819
902
|
installCommand,
|
|
820
903
|
runCommand,
|
|
904
|
+
uninstallCommand,
|
|
821
905
|
refreshOrbPolicyLessons,
|
|
822
906
|
};
|
package/commands/worktree.js
CHANGED
|
@@ -16,6 +16,9 @@ const close = require('./close');
|
|
|
16
16
|
// parallel landings that each carry a copy always clash.
|
|
17
17
|
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
|
|
18
18
|
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
// Push also runs local hooks. Backend startup and regression checks alone can
|
|
20
|
+
// exceed the short network-only allowance used by fetch and discovery.
|
|
21
|
+
const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
19
22
|
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
20
23
|
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
21
24
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -37,13 +40,15 @@ function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
|
|
|
37
40
|
// A killed network call leaves stderr empty; name the timeout instead.
|
|
38
41
|
function gitFailureDetail(result) {
|
|
39
42
|
if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
|
|
40
|
-
return
|
|
43
|
+
return [result && result.stdout, result && result.stderr]
|
|
44
|
+
.map(output => String(output || '').trim()).filter(Boolean).join('\n');
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
44
48
|
const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
|
|
45
49
|
// Only network calls need the SSH probe; local calls skip its extra git spawn.
|
|
46
|
-
const
|
|
50
|
+
const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
|
|
51
|
+
const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
|
|
47
52
|
killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
|
|
48
53
|
let result = spawnSync('git', args, options);
|
|
49
54
|
// Linked checkouts share config. A concurrent start must not silently lose
|
|
@@ -1118,6 +1123,23 @@ function shipWorktree(args) {
|
|
|
1118
1123
|
if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
|
|
1119
1124
|
throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
|
|
1120
1125
|
}
|
|
1126
|
+
// A merge queue accepts the request before merging. Deleting its head
|
|
1127
|
+
// here closes the PR and cancels that request, so confirm the outcome.
|
|
1128
|
+
const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt'], { cwd: root, encoding: 'utf8' });
|
|
1129
|
+
if (confirmation.status !== 0) {
|
|
1130
|
+
throw new Error('could not confirm the merge; the source branch was kept');
|
|
1131
|
+
}
|
|
1132
|
+
let state;
|
|
1133
|
+
try { state = JSON.parse(confirmation.stdout); } catch {
|
|
1134
|
+
throw new Error('could not read the merge result; the source branch was kept');
|
|
1135
|
+
}
|
|
1136
|
+
if (state.state === 'OPEN' && !state.mergedAt) {
|
|
1137
|
+
console.log('merge: pending; kept the source branch while required checks finish');
|
|
1138
|
+
return 0;
|
|
1139
|
+
}
|
|
1140
|
+
if (state.state !== 'MERGED' || !state.mergedAt) {
|
|
1141
|
+
throw new Error('the change was not merged; the source branch was kept');
|
|
1142
|
+
}
|
|
1121
1143
|
console.log('merge: merged');
|
|
1122
1144
|
const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
|
|
1123
1145
|
if (deleted.status === 0) {
|