atris 3.71.3 → 3.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +7 -1
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@ 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 until the judge passes, a budget (cloud only) runs out, or a person has to decide something.
|
|
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. A pass only counts when a second, fresh run of the judge passes right after it; a pass that does not repeat is recorded as a failure, so a flaky judge cannot end a campaign by luck.
|
|
16
16
|
|
|
17
17
|
## Size it first
|
|
18
18
|
|
package/bin/atris.js
CHANGED
|
@@ -533,6 +533,7 @@ function showHelpShort() {
|
|
|
533
533
|
console.log(' atris spaceship Keep working for a few hours');
|
|
534
534
|
console.log(' atris autopilot Keep working until you stop');
|
|
535
535
|
console.log(' atris mission the one goal');
|
|
536
|
+
console.log(' atris gm play your team as a front office');
|
|
536
537
|
console.log('');
|
|
537
538
|
console.log('More: atris help --all · machine list: atris help --json');
|
|
538
539
|
}
|
|
@@ -606,6 +607,7 @@ function showHelpAll() {
|
|
|
606
607
|
console.log(' who - Show local engines and team members working, waiting, done, or stale');
|
|
607
608
|
console.log(' stream - Watch the whole team work live in one terminal');
|
|
608
609
|
console.log(' team - One team view: members, roles, engine assignments (presence for live)');
|
|
610
|
+
console.log(' gm - Play your team as a front office: ratings, stuck jobs, league, replay, phone');
|
|
609
611
|
console.log(' host - The Host: gets to know people, makes two-yes introductions (atris member install host)');
|
|
610
612
|
console.log(' watch - Turn one sentence into an always-on background watcher');
|
|
611
613
|
console.log(' ctop - Show a process-first live agent CPU/memory view');
|
|
@@ -698,6 +700,7 @@ function showHelpAll() {
|
|
|
698
700
|
console.log(' pull - Pull journals + member data from cloud');
|
|
699
701
|
console.log(' push - Push workspace files to cloud');
|
|
700
702
|
console.log(' sync-checkout - fetch and fast-forward a clean current branch');
|
|
703
|
+
console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
|
|
701
704
|
console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
|
|
702
705
|
console.log(' live - Keep a business brain fresh (doctor, pull, watch, push)');
|
|
703
706
|
console.log(' clean-workspace <slug> - Analyze & remove junk files from a workspace (alias: cw)');
|
|
@@ -2305,6 +2308,9 @@ if (command === 'guide') {
|
|
|
2305
2308
|
} else if (command === 'sync-checkout') {
|
|
2306
2309
|
const code = require('../commands/sync-checkout').syncCheckoutCommand(process.argv.slice(3));
|
|
2307
2310
|
process.exit(code);
|
|
2311
|
+
} else if (command === 'zen') {
|
|
2312
|
+
const code = require('../commands/zen').zenCommand(process.argv.slice(3));
|
|
2313
|
+
process.exit(code);
|
|
2308
2314
|
} else if (command === 'update' || command === 'sync') {
|
|
2309
2315
|
const args = process.argv.slice(3);
|
|
2310
2316
|
const firstSyncArg = process.argv[3];
|
|
@@ -2971,7 +2977,7 @@ if (command === 'guide') {
|
|
|
2971
2977
|
.catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2972
2978
|
} else if (command === 'gm') {
|
|
2973
2979
|
require('../commands/gm').gmCommand(...process.argv.slice(3))
|
|
2974
|
-
.then(() => process.exit(0))
|
|
2980
|
+
.then(() => process.exit(process.exitCode || 0))
|
|
2975
2981
|
.catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2976
2982
|
} else if (command === 'game') {
|
|
2977
2983
|
require('../commands/game').gameCommand(process.argv.slice(3))
|
package/commands/autoland.js
CHANGED
|
@@ -742,6 +742,7 @@ async function runTick(root, args) {
|
|
|
742
742
|
else console.log('autoland is off; tick did nothing.');
|
|
743
743
|
return 0;
|
|
744
744
|
}
|
|
745
|
+
if (args.includes('--dry-run')) return runTickPreview(root, { json, receipt });
|
|
745
746
|
|
|
746
747
|
// One live tick at a time: verify re-runs can take minutes each, and a
|
|
747
748
|
// second hourly cron firing into the same snapshot double-accepts,
|
|
@@ -769,6 +770,44 @@ async function runTick(root, args) {
|
|
|
769
770
|
}
|
|
770
771
|
}
|
|
771
772
|
|
|
773
|
+
// `atris autoland tick --dry-run`: what the hourly pass would close, send
|
|
774
|
+
// back, and check, read only. No lock, no receipt, no GitHub writes, no job
|
|
775
|
+
// changes, no fetch.
|
|
776
|
+
function runTickPreview(root, { json, receipt }) {
|
|
777
|
+
receipt.dry_run = true;
|
|
778
|
+
runMergedWorkClose(root, receipt, { dryRun: true });
|
|
779
|
+
// The second check's next turn, in the order it would take the jobs.
|
|
780
|
+
try {
|
|
781
|
+
const taskDb = require('../lib/task-db');
|
|
782
|
+
const workspaceRoot = taskDb.workspaceRoot(mainCheckoutRoot(root) || root);
|
|
783
|
+
const certify = require('./task').cmdCertifyVerified([], { preview: true, silent: true, workspaceRoot });
|
|
784
|
+
receipt.second_check = {
|
|
785
|
+
would_check: certify.results.filter((r) => r.action === 'would_certify').map((r) => ({ ref: r.ref, verify: r.verify })),
|
|
786
|
+
skipped: certify.results.filter((r) => r.action === 'skipped').map((r) => ({ ref: r.ref, reason: r.reason })),
|
|
787
|
+
};
|
|
788
|
+
} catch (err) {
|
|
789
|
+
receipt.second_check_error = String((err && err.message) || err).slice(0, 200);
|
|
790
|
+
}
|
|
791
|
+
if (json) {
|
|
792
|
+
console.log(JSON.stringify(receipt));
|
|
793
|
+
return 0;
|
|
794
|
+
}
|
|
795
|
+
const close = receipt.merged_close || {};
|
|
796
|
+
console.log(`autoland tick (dry run): would close ${(close.would_close || []).length} merged job${(close.would_close || []).length === 1 ? '' : 's'}, would send ${(close.would_send_back || []).length} clashing job${(close.would_send_back || []).length === 1 ? '' : 's'} back`);
|
|
797
|
+
const prLine = (row) => (row.action === 'merged_earlier'
|
|
798
|
+
? `merged earlier, closed without counting it as a new landing: its own pull request #${row.number} merged over a day ago`
|
|
799
|
+
: `its own pull request #${row.number} merged`);
|
|
800
|
+
for (const row of close.would_close || []) console.log(` close ${row.ref || row.id}: ${row.proof || prLine(row)}`);
|
|
801
|
+
for (const row of close.would_send_back || []) console.log(` send back ${row.id}: pull request #${row.number} clashes`);
|
|
802
|
+
for (const line of close.merge_line || []) console.log(` ${line}`);
|
|
803
|
+
if (receipt.merged_close_error) console.log(` could not check merged work: ${receipt.merged_close_error}`);
|
|
804
|
+
const check = receipt.second_check || { would_check: [], skipped: [] };
|
|
805
|
+
console.log(`second check: would check ${check.would_check.length} job${check.would_check.length === 1 ? '' : 's'} next, ${check.skipped.length} skipped`);
|
|
806
|
+
for (const row of check.would_check) console.log(` check ${row.ref}: ${row.verify}`);
|
|
807
|
+
if (receipt.second_check_error) console.log(` could not preview the second check: ${receipt.second_check_error}`);
|
|
808
|
+
return 0;
|
|
809
|
+
}
|
|
810
|
+
|
|
772
811
|
function persistTickReceipt(root, receipt) {
|
|
773
812
|
const runsDir = path.join(root, 'atris', 'runs');
|
|
774
813
|
fs.mkdirSync(runsDir, { recursive: true });
|
|
@@ -916,6 +955,57 @@ function engineAnswerValidationLine(receipt) {
|
|
|
916
955
|
return `graded ${graded} engine answer${graded === 1 ? '' : 's'}, scoreboard updated`;
|
|
917
956
|
}
|
|
918
957
|
|
|
958
|
+
// Merged work closes its jobs; clashing work goes back on the list. Until
|
|
959
|
+
// 2026-10-22 this ran only inside the team wake loop, which last swept on
|
|
960
|
+
// 2026-10-15, so four merged jobs waited a week. Two sources: the autoland
|
|
961
|
+
// list (pull requests GitHub merged at the member's own commit, through the
|
|
962
|
+
// team loop's shared five-minute pass) and git itself (lib/merged-review-close.js)
|
|
963
|
+
// for jobs that left the list. dryRun reads and reports, and writes nothing.
|
|
964
|
+
function runMergedWorkClose(root, receipt, { dryRun = false, now = Date.now() } = {}) {
|
|
965
|
+
const out = dryRun
|
|
966
|
+
? { would_close: [], would_send_back: [], merge_line: [] }
|
|
967
|
+
: { closed: [], sent_back: [], merge_line: [] };
|
|
968
|
+
const closedList = dryRun ? out.would_close : out.closed;
|
|
969
|
+
const sentList = dryRun ? out.would_send_back : out.sent_back;
|
|
970
|
+
try {
|
|
971
|
+
const memberAutoland = require('../lib/member-autoland');
|
|
972
|
+
const queue = dryRun
|
|
973
|
+
? memberAutoland.requeueDroppedAutolands({ cwd: root, now, dryRun: true })
|
|
974
|
+
: memberAutoland.sweepAfterMissionRun({ cwd: root, now });
|
|
975
|
+
for (const r of (queue && queue.results) || []) {
|
|
976
|
+
if (r.line) out.merge_line.push(r.line);
|
|
977
|
+
for (const id of r.closed || []) closedList.push({ id, how: 'pull_request', number: r.number, action: r.stale ? 'merged_earlier' : 'merged' });
|
|
978
|
+
for (const id of r.sent_back || []) sentList.push({ id, number: r.number });
|
|
979
|
+
}
|
|
980
|
+
const taskDb = require('../lib/task-db');
|
|
981
|
+
const db = taskDb.open();
|
|
982
|
+
const workspace = taskDb.workspaceRoot(mainCheckoutRoot(root) || root);
|
|
983
|
+
const tasks = taskDb.listTasks(db, { workspaceRoot: workspace, status: 'review' })
|
|
984
|
+
.filter((t) => !closedList.some((c) => c.id === t.id));
|
|
985
|
+
const scan = require('../lib/merged-review-close').closeMergedReviewJobs({ root, tasks, now, dryRun });
|
|
986
|
+
for (const row of dryRun ? scan.matched : scan.closed) closedList.push(row);
|
|
987
|
+
out.checked = scan.checked;
|
|
988
|
+
} catch (err) {
|
|
989
|
+
receipt.merged_close_error = String((err && err.message) || err).slice(0, 200);
|
|
990
|
+
}
|
|
991
|
+
receipt.merged_close = out;
|
|
992
|
+
return out;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function mainCheckoutRoot(root) {
|
|
996
|
+
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, encoding: 'utf8' });
|
|
997
|
+
if (result.status !== 0 || !result.stdout.trim()) return null;
|
|
998
|
+
return path.dirname(result.stdout.trim());
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function mergedCloseNote(receipt) {
|
|
1002
|
+
const closed = (receipt.merged_close?.closed || []).length;
|
|
1003
|
+
const sent = (receipt.merged_close?.sent_back || []).length;
|
|
1004
|
+
return (closed ? `, closed ${closed} merged job${closed === 1 ? '' : 's'}` : '')
|
|
1005
|
+
+ (sent ? `, sent ${sent} clashing job${sent === 1 ? '' : 's'} back on the list` : '')
|
|
1006
|
+
+ (receipt.merged_close_error ? ', could not check which jobs already merged' : '');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
919
1009
|
async function runTickBody(root, { json, policy, receipt, engineValidationDeps = {} }) {
|
|
920
1010
|
|
|
921
1011
|
try {
|
|
@@ -940,7 +1030,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
|
|
|
940
1030
|
// Refresh origin once, before anything is accepted: the landing proof reads
|
|
941
1031
|
// origin's target, and a stale ref would call landed work unlanded.
|
|
942
1032
|
refreshLandingRefs(root);
|
|
943
|
-
const cliArgs =
|
|
1033
|
+
const cliArgs =['task', 'auto-accept-certified', '--json', '--certify-first'];
|
|
944
1034
|
if (policy.accept_all) cliArgs.push('--all');
|
|
945
1035
|
else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
|
|
946
1036
|
const pendingReviewRows = readProjection(root)
|
|
@@ -1008,6 +1098,14 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
|
|
|
1008
1098
|
receipt.accept_error = describeAcceptKillCause(accept) || accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
|
|
1009
1099
|
}
|
|
1010
1100
|
|
|
1101
|
+
// 2a. merged work closes its jobs and clashing work goes back on the list,
|
|
1102
|
+
// every hour, whether or not a team loop is running. After the accept step,
|
|
1103
|
+
// so work that can be checked and landed the normal way is landed first.
|
|
1104
|
+
const mergedClose = runMergedWorkClose(root, receipt);
|
|
1105
|
+
if ((mergedClose.closed || []).length + (mergedClose.sent_back || []).length > 0) {
|
|
1106
|
+
try { require('./task').refreshKeptTaskList(root); } catch {}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1011
1109
|
// 2b. tell the operator the moment something lands: one text, one landing
|
|
1012
1110
|
// sentence per piece (the day-one PM sentence written at finish time),
|
|
1013
1111
|
// capped at three with the rest counted. Off with live_updates: false.
|
|
@@ -1312,7 +1410,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
|
|
|
1312
1410
|
// Always print the standing backlog, and name work that cleared every
|
|
1313
1411
|
// gate and still did not land. That is the shape of a stuck loop.
|
|
1314
1412
|
const stuckNote = stuckBacklogNote(receipt);
|
|
1315
|
-
const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
|
|
1413
|
+
const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}${mergedCloseNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
|
|
1316
1414
|
const validationLine = engineAnswerValidationLine(receipt);
|
|
1317
1415
|
if (validationLine) console.log(validationLine);
|
|
1318
1416
|
console.log(gateForHuman(summary).text);
|
|
@@ -1346,6 +1444,8 @@ function showHelp() {
|
|
|
1346
1444
|
console.log(' atris autoland off back to approving every item');
|
|
1347
1445
|
console.log(' atris autoland digest [--send] the daily message, now');
|
|
1348
1446
|
console.log(' atris autoland tick [--json] one heartbeat (what the hourly cron runs)');
|
|
1447
|
+
console.log(' atris autoland tick --dry-run what the heartbeat would close, send back,');
|
|
1448
|
+
console.log(' and check, without changing anything');
|
|
1349
1449
|
console.log('');
|
|
1350
1450
|
console.log('help is always read-only: atris autoland tick --help never lands work.');
|
|
1351
1451
|
console.log('');
|
package/commands/campaign.js
CHANGED
|
@@ -14,9 +14,10 @@ const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign
|
|
|
14
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
|
-
const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
|
|
17
|
+
const { confirmedReading, judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
|
|
18
18
|
const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
|
|
19
19
|
const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
|
|
20
|
+
const schedules = require('../lib/campaign-schedule');
|
|
20
21
|
|
|
21
22
|
const JUDGE_TIMEOUT_SECONDS = 15 * 60;
|
|
22
23
|
// Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
|
|
@@ -53,6 +54,9 @@ function printHelp() {
|
|
|
53
54
|
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
55
|
console.log(' atris campaign wake one step for every due campaign; a wait not yet due is skipped');
|
|
55
56
|
console.log(' atris campaign keep --install|--remove [--dry-run] a one-minute timer that runs wake (launchd or cron)');
|
|
57
|
+
console.log(' atris campaign schedule <id> --every "weekdays 09:00" [--steps 4] a person says yes once; wake and tick');
|
|
58
|
+
console.log(' start the campaign at those times until its budget is spent. --every also takes "daily 18:30",');
|
|
59
|
+
console.log(' "mon,thu 07:00", or "30m". atris campaign schedule <id> --off removes it; no id lists every schedule');
|
|
56
60
|
console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
|
|
57
61
|
console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
|
|
58
62
|
console.log(' atris campaign rsi [id] [--no-write] distill trace rewards and lessons across campaigns');
|
|
@@ -85,7 +89,7 @@ const NO_LIMIT_BUDGET = 1e9;
|
|
|
85
89
|
const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
|
|
86
90
|
|
|
87
91
|
// The flags this command understands, so positional words are what is left.
|
|
88
|
-
const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
|
|
92
|
+
const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect', '--every', '--steps'];
|
|
89
93
|
|
|
90
94
|
function positionals(args) {
|
|
91
95
|
const out = [];
|
|
@@ -522,7 +526,9 @@ function describeCloudRun(data) {
|
|
|
522
526
|
function cmdShow(args, ctx) {
|
|
523
527
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
524
528
|
if (error !== undefined) return error;
|
|
525
|
-
|
|
529
|
+
const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
|
|
530
|
+
const text = schedule ? `${renderShow(view)}\nschedule ${schedules.describe(schedule)}` : renderShow(view);
|
|
531
|
+
ctx.out(text, { ok: true, campaign: view, next: decide(view), schedule });
|
|
526
532
|
return 0;
|
|
527
533
|
}
|
|
528
534
|
|
|
@@ -555,7 +561,9 @@ async function cmdJudge(args, ctx) {
|
|
|
555
561
|
if (error !== undefined) return error;
|
|
556
562
|
const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
|
|
557
563
|
if (!ctx.asJson) console.error(`running the judge: ${view.judge}`);
|
|
558
|
-
|
|
564
|
+
// A pass that would finish the campaign must pass a second, fresh run first.
|
|
565
|
+
const run = () => ctx.judge(view.judge, timeout, view.id);
|
|
566
|
+
const reading = view.standing ? await run() : await confirmedReading(run);
|
|
559
567
|
ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
|
|
560
568
|
const after = getCampaign(ctx.store, view.id);
|
|
561
569
|
const verdict = reading.passed ? 'passed' : 'failed';
|
|
@@ -821,12 +829,12 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
821
829
|
}
|
|
822
830
|
const result = await cadence.run(view.id);
|
|
823
831
|
ctx.out(`stopped after ${result.rounds} round${result.rounds === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign) });
|
|
824
|
-
return 0;
|
|
832
|
+
return result.ok === false ? 1 : 0;
|
|
825
833
|
}
|
|
826
834
|
const maxSteps = Number(readFlag(args, '--max-steps', '')) || 20;
|
|
827
835
|
const result = await loop.run(view.id, maxSteps);
|
|
828
836
|
ctx.out(`stopped after ${result.steps} step${result.steps === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign), sync_warnings: syncWarnings });
|
|
829
|
-
return 0;
|
|
837
|
+
return result.ok === false ? 1 : 0;
|
|
830
838
|
}
|
|
831
839
|
|
|
832
840
|
// atris campaign tick: one bounded push on the next campaign in this workspace that has
|
|
@@ -836,19 +844,27 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
836
844
|
async function cmdTick(args, ctx) {
|
|
837
845
|
const { listCampaigns } = require('../lib/campaign-store');
|
|
838
846
|
const { liveHolder } = require('../lib/campaign-run-lock');
|
|
839
|
-
const
|
|
847
|
+
const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
|
|
848
|
+
let maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
|
|
840
849
|
const skipped = [];
|
|
841
850
|
let pick = null;
|
|
842
851
|
for (const view of listCampaigns(ctx.store)) {
|
|
843
852
|
if (view.error) { skipped.push({ id: view.id, why: `unreadable: ${view.error}` }); continue; }
|
|
844
853
|
const move = decide(view);
|
|
845
854
|
if (move.action === 'none') continue;
|
|
855
|
+
// A scheduled campaign moves only when its time comes (lib/campaign-schedule.js).
|
|
856
|
+
const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
|
|
857
|
+
if (schedule && !schedules.isDue(schedule, now())) { skipped.push({ id: view.id, why: `scheduled: next run ${schedules.whenText(schedule.next_at)}` }); continue; }
|
|
846
858
|
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
859
|
if (move.action === 'wait' && move.reason === 'budget') { skipped.push({ id: view.id, why: 'waiting for budget' }); continue; }
|
|
848
860
|
// A campaign sleeping until its next check is skipped, so the next one with work gets the push.
|
|
849
861
|
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
862
|
const holder = ctx.campaignHome ? liveHolder(ctx.campaignHome, view.id) : null;
|
|
851
863
|
if (holder) { skipped.push({ id: view.id, why: `another run drives it (pid ${holder.pid})` }); continue; }
|
|
864
|
+
if (schedule) {
|
|
865
|
+
schedules.markFired(ctx.campaignHome, view.id, schedule, now());
|
|
866
|
+
if (!readFlag(args, '--max-steps', '')) maxSteps = schedule.steps;
|
|
867
|
+
}
|
|
852
868
|
pick = view;
|
|
853
869
|
break;
|
|
854
870
|
}
|
|
@@ -881,28 +897,44 @@ async function cmdWake(args, ctx) {
|
|
|
881
897
|
if (view.error) { skipped.push({ campaign_id: view.id, reason: `cannot be read: ${view.error}` }); continue; }
|
|
882
898
|
const move = decide(view);
|
|
883
899
|
if (move.action === 'none') { skipped.push({ campaign_id: view.id, reason: move.reason }); continue; }
|
|
900
|
+
// A scheduled campaign waits for its time, then gets a whole bounded run,
|
|
901
|
+
// one mission at a time, instead of one step a minute.
|
|
902
|
+
const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
|
|
903
|
+
if (schedule && !schedules.isDue(schedule, now())) {
|
|
904
|
+
skipped.push({ campaign_id: view.id, reason: `scheduled: next run ${schedules.whenText(schedule.next_at)}` });
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
884
907
|
const saved = view.next_move;
|
|
885
|
-
if (saved && saved.action === 'wait' && now() < saved.wake_at) {
|
|
908
|
+
if (!schedule && saved && saved.action === 'wait' && now() < saved.wake_at) {
|
|
886
909
|
skipped.push({ campaign_id: view.id, reason: `next check at ${new Date(saved.wake_at).toISOString()}` });
|
|
887
910
|
continue;
|
|
888
911
|
}
|
|
912
|
+
if (schedule) schedules.markFired(ctx.campaignHome, view.id, schedule, now());
|
|
913
|
+
const loopArgs = schedule
|
|
914
|
+
? ['run', view.id, '--max-steps', String(schedule.steps), '--parallel', '1', ...flags]
|
|
915
|
+
: ['step', view.id, ...flags];
|
|
889
916
|
let step = null;
|
|
890
917
|
let code;
|
|
891
918
|
try {
|
|
892
|
-
code = await cmdLoop(
|
|
919
|
+
code = await cmdLoop(loopArgs, {
|
|
893
920
|
...ctx,
|
|
894
921
|
out: (text, data) => { step = data; },
|
|
895
922
|
fail: (error, failCode = 2) => { step = { ok: false, error }; return failCode; },
|
|
896
|
-
}, 'step');
|
|
923
|
+
}, schedule ? 'run' : 'step');
|
|
897
924
|
} catch (err) {
|
|
898
925
|
code = 1;
|
|
899
926
|
step = { ok: false, error: err.message };
|
|
900
927
|
}
|
|
901
928
|
const { campaign, next, ...result } = step || {};
|
|
902
|
-
woke.push({ campaign_id: view.id, ...result, ok: code === 0 && result.ok !== false });
|
|
929
|
+
woke.push({ campaign_id: view.id, ...result, ...(schedule ? { scheduled: true } : {}), ok: code === 0 && result.ok !== false });
|
|
903
930
|
}
|
|
931
|
+
const said = (w) => {
|
|
932
|
+
if (!w.ok) return `failed: ${w.error || w.reason}`;
|
|
933
|
+
if (w.scheduled) return `ran on schedule, ${w.steps} step${w.steps === 1 ? '' : 's'}: ${w.reason}`;
|
|
934
|
+
return w.stop ? `stopped: ${w.reason}` : `did: ${w.did}`;
|
|
935
|
+
};
|
|
904
936
|
const lines = [
|
|
905
|
-
...woke.map((w) => `${w.campaign_id} ${
|
|
937
|
+
...woke.map((w) => `${w.campaign_id} ${said(w)}`),
|
|
906
938
|
...skipped.map((s) => `${s.campaign_id} skipped: ${s.reason}`),
|
|
907
939
|
];
|
|
908
940
|
const head = `woke ${woke.length} campaign${woke.length === 1 ? '' : 's'}, skipped ${skipped.length}`;
|
|
@@ -910,6 +942,47 @@ async function cmdWake(args, ctx) {
|
|
|
910
942
|
return 0;
|
|
911
943
|
}
|
|
912
944
|
|
|
945
|
+
// A person's standing yes: the campaign starts on its own at set times until
|
|
946
|
+
// its budget is spent. Agents may remove a schedule but never set one.
|
|
947
|
+
function cmdSchedule(args, ctx) {
|
|
948
|
+
const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
|
|
949
|
+
const id = positionals(args)[1];
|
|
950
|
+
if (!id) {
|
|
951
|
+
const rows = listCampaigns(ctx.store).filter((view) => !view.error)
|
|
952
|
+
.map((view) => ({ view, schedule: schedules.readSchedule(ctx.campaignHome, view.id) }))
|
|
953
|
+
.filter((row) => row.schedule);
|
|
954
|
+
const text = rows.length ? rows.map((row) => `${row.view.id} ${schedules.describe(row.schedule)}`).join('\n') : 'no campaign here has a schedule';
|
|
955
|
+
ctx.out(text, { ok: true, schedules: rows.map((row) => row.schedule) });
|
|
956
|
+
return 0;
|
|
957
|
+
}
|
|
958
|
+
const { view, error } = requireCampaign(ctx, id);
|
|
959
|
+
if (error !== undefined) return error;
|
|
960
|
+
if (args.includes('--off')) {
|
|
961
|
+
const removed = schedules.removeSchedule(ctx.campaignHome, view.id);
|
|
962
|
+
ctx.out(removed ? `${view.id} no longer runs on a schedule` : `${view.id} had no schedule`, { ok: true, removed });
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
const every = readFlag(args, '--every', '');
|
|
966
|
+
if (!every) {
|
|
967
|
+
const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
|
|
968
|
+
ctx.out(schedule ? `${view.id} ${schedules.describe(schedule)}` : `${view.id} has no schedule. Set one: atris campaign schedule ${view.id} --every "weekdays 09:00"`, { ok: true, schedule });
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
if (!isHumanActor(ctx.actor)) return ctx.fail('a schedule starts work with nobody watching, so only a person can set one. Ask them: atris campaign ask <id> "Run this every ...?"');
|
|
972
|
+
if (view.state === 'done') return ctx.fail(`${view.id} is done; a schedule would never run`);
|
|
973
|
+
if (noLimit(view)) return ctx.fail(`${view.id} has no budget, and a schedule runs with nobody watching. Start it with --budget, then schedule it`);
|
|
974
|
+
const parsed = schedules.parseEvery(every);
|
|
975
|
+
if (parsed.error) return ctx.fail(parsed.error);
|
|
976
|
+
const stepsFlag = readFlag(args, '--steps', '');
|
|
977
|
+
const steps = stepsFlag ? Math.floor(Number(stepsFlag)) : schedules.DEFAULT_STEPS;
|
|
978
|
+
if (!(steps >= 1)) return ctx.fail('--steps must be a whole number, 1 or more');
|
|
979
|
+
const schedule = schedules.saveSchedule(ctx.campaignHome, view.id, { every: parsed.every, rule: parsed.rule, steps, by: ctx.actor, now: now() });
|
|
980
|
+
const left = `stops on its own when the ${money(view.budget.available)} left is spent`;
|
|
981
|
+
const fires = 'atris campaign keep --install starts it on time here; the overnight heartbeat (atris pulse) also fires it';
|
|
982
|
+
ctx.out(`${view.id} runs ${schedules.describe(schedule)}. It ${left}.\n${fires}`, { ok: true, schedule });
|
|
983
|
+
return 0;
|
|
984
|
+
}
|
|
985
|
+
|
|
913
986
|
// A one-minute timer that runs `atris campaign wake` for this checkout.
|
|
914
987
|
function cmdKeep(args, ctx) {
|
|
915
988
|
const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
|
|
@@ -932,7 +1005,7 @@ function cmdKeep(args, ctx) {
|
|
|
932
1005
|
}
|
|
933
1006
|
|
|
934
1007
|
const VERBS = {
|
|
935
|
-
wake: cmdWake, keep: cmdKeep,
|
|
1008
|
+
wake: cmdWake, keep: cmdKeep, schedule: cmdSchedule,
|
|
936
1009
|
start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
|
|
937
1010
|
pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
|
|
938
1011
|
ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
|
package/commands/clean.js
CHANGED
|
@@ -634,6 +634,42 @@ function findStalePages(cwd, atrisDir) {
|
|
|
634
634
|
return stalePages;
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
/**
|
|
638
|
+
* When a source last changed. A checkout or rebase touches mtimes without
|
|
639
|
+
* changing content, so a file git holds unchanged counts from its last commit.
|
|
640
|
+
*/
|
|
641
|
+
function contentChangedAt(sourcePath, mtime) {
|
|
642
|
+
const dir = path.dirname(sourcePath);
|
|
643
|
+
const name = path.basename(sourcePath);
|
|
644
|
+
const status = spawnSync('git', ['status', '--porcelain', '--', name], { cwd: dir, encoding: 'utf8' });
|
|
645
|
+
if (status.status !== 0 || status.stdout.trim()) return mtime;
|
|
646
|
+
const log = spawnSync('git', ['log', '-1', '--format=%cI', '--', name], { cwd: dir, encoding: 'utf8' });
|
|
647
|
+
const committed = log.status === 0 ? log.stdout.trim() : '';
|
|
648
|
+
return committed ? new Date(committed) : mtime;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function looksLikePath(text) {
|
|
652
|
+
if (!text || /\s/.test(text) || /^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return false;
|
|
653
|
+
return text.includes('/') || /\.[A-Za-z0-9]+$/.test(text);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// A relative source can be written from the repo root, the page's own folder,
|
|
657
|
+
// or any folder in between (a brief citing "briefs/x.md" from the wiki root).
|
|
658
|
+
// The first place it exists wins; if none, the repo-root path is reported.
|
|
659
|
+
function resolveSource(cwd, pagePath, filePart) {
|
|
660
|
+
if (path.isAbsolute(filePart)) return filePart;
|
|
661
|
+
const root = path.resolve(cwd);
|
|
662
|
+
const fromRoot = path.join(root, filePart);
|
|
663
|
+
if (fs.existsSync(fromRoot)) return fromRoot;
|
|
664
|
+
let dir = path.dirname(path.resolve(pagePath));
|
|
665
|
+
while (dir.startsWith(root + path.sep)) {
|
|
666
|
+
const candidate = path.join(dir, filePart);
|
|
667
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
668
|
+
dir = path.dirname(dir);
|
|
669
|
+
}
|
|
670
|
+
return fromRoot;
|
|
671
|
+
}
|
|
672
|
+
|
|
637
673
|
/**
|
|
638
674
|
* Parse frontmatter from a markdown file and check if any source is newer than last_compiled.
|
|
639
675
|
* Returns { page, staleSource, compiledDate, sourceDate } or null if not stale.
|
|
@@ -675,15 +711,19 @@ function checkPageStaleness(cwd, filePath) {
|
|
|
675
711
|
.replace(/\s*\([^)]*\)\s*$/, '') // drop trailing "(annotation)"
|
|
676
712
|
.replace(/:\d+(-\d+)?$/, '') // drop trailing ":N" or ":N-M"
|
|
677
713
|
.trim();
|
|
678
|
-
|
|
714
|
+
// A plain-language source ("Voyage AI documentation") or a web link names
|
|
715
|
+
// no file, so it can never be missing on disk. Only paths are checked.
|
|
716
|
+
if (!looksLikePath(filePart)) continue;
|
|
717
|
+
const sourcePath = resolveSource(cwd, filePath, filePart);
|
|
679
718
|
try {
|
|
680
719
|
const stat = fs.statSync(sourcePath);
|
|
681
|
-
|
|
720
|
+
const changed = stat.mtime > compiledDate ? contentChangedAt(sourcePath, stat.mtime) : null;
|
|
721
|
+
if (changed && changed > compiledDate) {
|
|
682
722
|
return {
|
|
683
723
|
page: filePath,
|
|
684
724
|
staleSource: source,
|
|
685
725
|
compiledDate: compiledMatch[1],
|
|
686
|
-
sourceDate:
|
|
726
|
+
sourceDate: changed.toISOString().split('T')[0]
|
|
687
727
|
};
|
|
688
728
|
}
|
|
689
729
|
} catch {
|
|
@@ -700,6 +740,22 @@ function checkPageStaleness(cwd, filePath) {
|
|
|
700
740
|
return null;
|
|
701
741
|
}
|
|
702
742
|
|
|
743
|
+
// A day can already be archived when a late entry lands in its journal.
|
|
744
|
+
// Renaming over the archive would erase it, so the late entry is added to the
|
|
745
|
+
// end instead, unless the archive already holds it word for word.
|
|
746
|
+
function moveIntoArchive(src, dest) {
|
|
747
|
+
if (!fs.existsSync(dest)) {
|
|
748
|
+
fs.renameSync(src, dest);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const late = fs.readFileSync(src, 'utf8');
|
|
752
|
+
const kept = fs.readFileSync(dest, 'utf8');
|
|
753
|
+
if (late.trim() && !kept.includes(late.trim())) {
|
|
754
|
+
fs.writeFileSync(dest, `${kept.replace(/\n*$/, '\n')}\n${late}`);
|
|
755
|
+
}
|
|
756
|
+
fs.rmSync(src);
|
|
757
|
+
}
|
|
758
|
+
|
|
703
759
|
/**
|
|
704
760
|
* Archive journals older than 30 days
|
|
705
761
|
*/
|
|
@@ -734,7 +790,7 @@ function archiveOldJournals(atrisDir, dryRun = false) {
|
|
|
734
790
|
}
|
|
735
791
|
const src = path.join(yearDir, file);
|
|
736
792
|
const dest = path.join(archiveYearDir, file);
|
|
737
|
-
|
|
793
|
+
moveIntoArchive(src, dest);
|
|
738
794
|
}
|
|
739
795
|
archived++;
|
|
740
796
|
}
|