atris 3.71.2 → 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.
Files changed (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
package/commands/task.js CHANGED
@@ -29,10 +29,10 @@ const {
29
29
  parseVerifyChain,
30
30
  parseVerifyCommand,
31
31
  runVerifyCommand,
32
- runVerifyCommandCached,
33
32
  DENIED_TAGS,
34
33
  } = require('../lib/auto-accept-certified');
35
34
  const { evaluateAcceptVerify } = require('../lib/accept-verify-gate');
35
+ const { checkedCodeSha, runVerifyAtJobCode } = require('../lib/job-code-verify');
36
36
  const { proveTaskLanding } = require('../lib/landing-proof');
37
37
  const {
38
38
  taskExplanation,
@@ -10645,22 +10645,44 @@ function stampVerifyCacheMetadata(taskDb, db, taskId, { verify, result, actor, w
10645
10645
  reason: (result && result.reason) || null,
10646
10646
  ran_at: new Date().toISOString(),
10647
10647
  ran_by: actor,
10648
- code_sha: currentHeadSha(workspaceRoot),
10648
+ // The commit the check ran on: the job's own code when it has some,
10649
+ // else the folder the job lives in.
10650
+ code_sha: (result && result.code_sha) || currentHeadSha(workspaceRoot),
10651
+ code_source: (result && result.code_source) || 'checkout',
10649
10652
  };
10653
+ // A check is not a touch. Bumping updated_at here put every failed job at
10654
+ // the front of the next pass, so the same six failing jobs won every hour.
10650
10655
  db.prepare(`
10651
10656
  UPDATE tasks
10652
- SET metadata = ?,
10653
- updated_at = ?
10657
+ SET metadata = ?
10654
10658
  WHERE id = ?
10655
- `).run(JSON.stringify(metadata), Date.now(), taskId);
10659
+ `).run(JSON.stringify(metadata), taskId);
10660
+ }
10661
+
10662
+ // When a review job was last given a second check; never checked reads 0.
10663
+ function lastCheckedMs(task) {
10664
+ const ranAt = Date.parse(String(task?.metadata?.verify_cache?.ran_at || ''));
10665
+ return Number.isFinite(ranAt) ? ranAt : 0;
10666
+ }
10667
+
10668
+ // The check already failed on exactly this code: running it again proves
10669
+ // nothing until the code changes.
10670
+ function failedOnSameCode(task, verify, codeSha) {
10671
+ const cache = task?.metadata?.verify_cache;
10672
+ return Boolean(cache && typeof cache === 'object' && cache.ok === false && cache.reason === 'verify_failed'
10673
+ && cache.command === verify && codeSha && cache.code_sha === codeSha);
10656
10674
  }
10657
10675
 
10658
10676
  function cachedVerifyVerdict(task, root) {
10659
10677
  const metadata = (task && task.metadata) || {};
10660
10678
  const cache = metadata.verify_cache;
10661
10679
  if (!cache || typeof cache !== 'object') return { fresh: false, command: metadata.verify || null };
10680
+ // A check run on the job's own code stays fresh until that code changes.
10681
+ const currentCode = cache.code_source && cache.code_source !== 'checkout'
10682
+ ? checkedCodeSha(task, root)
10683
+ : currentHeadSha(task.workspace_root || root);
10662
10684
  const fresh = cache.command === metadata.verify
10663
- && (!cache.code_sha || cache.code_sha === currentHeadSha(task.workspace_root || root));
10685
+ && (!cache.code_sha || cache.code_sha === currentCode);
10664
10686
  return { ...cache, fresh: Boolean(fresh) };
10665
10687
  }
10666
10688
 
@@ -10711,7 +10733,7 @@ function reverifyBeforeLanding(taskDb, db, task, { actor = 'autoland-verifier',
10711
10733
  revise_reason: revised.reason || null,
10712
10734
  };
10713
10735
  }
10714
- const result = runVerifyCommandCached(verify, task.workspace_root || process.cwd(), verifyCache);
10736
+ const result = runVerifyAtJobCode(verify, task, { root: process.cwd(), cache: verifyCache });
10715
10737
  stampVerifyCacheMetadata(taskDb, db, task.id, { verify, result, actor, workspaceRoot: task.workspace_root });
10716
10738
  if (result.ok) return { ok: true, verify, result };
10717
10739
  const note = landingVerifyFailureNote(verify, result);
@@ -10731,7 +10753,10 @@ function reverifyBeforeLanding(taskDb, db, task, { actor = 'autoland-verifier',
10731
10753
  }
10732
10754
 
10733
10755
  function cmdCertifyVerified(args, options = {}) {
10734
- const dryRun = hasFlag(args, '--dry-run');
10756
+ // preview: the hourly pass's dry run. Reads the named workspace's jobs and
10757
+ // writes nothing, not even the task list file.
10758
+ const preview = options.preview === true;
10759
+ const dryRun = preview || hasFlag(args, '--dry-run');
10735
10760
  const asJson = wantsJson(args);
10736
10761
  const silent = options.silent === true;
10737
10762
  const verifyCache = options.verifyCache || null;
@@ -10741,13 +10766,18 @@ function cmdCertifyVerified(args, options = {}) {
10741
10766
 
10742
10767
  const taskDb = getTaskDb();
10743
10768
  const db = taskDb.open();
10744
- const { projection } = writeDefaultProjection(taskDb, db);
10769
+ const projection = preview
10770
+ ? enrichTaskProjection(taskDb.taskProjection(db, { workspaceRoot: options.workspaceRoot || taskDb.workspaceRoot(), limit: 500 }))
10771
+ : writeDefaultProjection(taskDb, db).projection;
10772
+ // Least recently checked first, oldest first among never-checked jobs. The
10773
+ // limit counts checks that actually run, so skipped rows never use a turn.
10745
10774
  const candidates = (projection.tasks || [])
10746
10775
  .filter((t) => t && t.status === 'review')
10747
- .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))
10748
- .slice(0, max);
10776
+ .sort((a, b) => lastCheckedMs(a) - lastCheckedMs(b) || Number(a.updated_at || 0) - Number(b.updated_at || 0));
10749
10777
  const results = [];
10778
+ let checks = 0;
10750
10779
  for (const item of candidates) {
10780
+ if (checks >= max) break;
10751
10781
  const fullProjection = enrichTaskProjection(taskDb.taskProjection(db, { taskId: item.id }));
10752
10782
  const task = fullProjection.tasks[0] || null;
10753
10783
  if (!task) {
@@ -10809,11 +10839,16 @@ function cmdCertifyVerified(args, options = {}) {
10809
10839
  results.push({ ref, action: 'skipped', reason: 'no_runnable_check_in_proof' });
10810
10840
  continue;
10811
10841
  }
10842
+ if (failedOnSameCode(task, verify, checkedCodeSha(task, process.cwd()))) {
10843
+ results.push({ ref, action: 'skipped', reason: 'failed_on_this_code', verify });
10844
+ continue;
10845
+ }
10846
+ checks += 1;
10812
10847
  if (dryRun) {
10813
10848
  results.push({ ref, action: 'would_certify', verify });
10814
10849
  continue;
10815
10850
  }
10816
- const run = runVerifyCommandCached(verify, task.workspace_root || process.cwd(), verifyCache);
10851
+ const run = runVerifyAtJobCode(verify, task, { root: process.cwd(), cache: verifyCache });
10817
10852
  stampVerifyCacheMetadata(taskDb, db, task.id, { verify, result: run, actor, workspaceRoot: task.workspace_root });
10818
10853
  if (!run.ok) {
10819
10854
  results.push({ ref, action: 'verify_failed', reason: run.reason, verify });
@@ -10840,7 +10875,7 @@ function cmdCertifyVerified(args, options = {}) {
10840
10875
  const certifiedNow = readied.event?.payload?.agent_certified === true;
10841
10876
  results.push({ ref, action: certifiedNow ? 'certified' : 'pass_recorded', verify });
10842
10877
  }
10843
- const { outPath } = writeDefaultProjection(taskDb, db);
10878
+ const outPath = preview ? null : writeDefaultProjection(taskDb, db).outPath;
10844
10879
  const certified = results.filter((r) => r.action === 'certified').length;
10845
10880
  const payload = {
10846
10881
  ok: true,
@@ -13698,6 +13733,7 @@ module.exports = {
13698
13733
  delegateTask,
13699
13734
  AGENT_ENV_MARKERS,
13700
13735
  autoRenderTodoFromDb,
13736
+ cmdCertifyVerified,
13701
13737
  refreshKeptTaskList,
13702
13738
  keptCount,
13703
13739
  projectionMissions,
@@ -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 String((result && (result.stderr || result.stdout)) || '').trim();
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 options = { cwd, encoding: 'utf8', timeout: timeout ?? (network ? 30000 : undefined),
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) {
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const { abandonedDaysFromEnv, checkZen, sideCopyLines } = require('../lib/zen');
4
+ const { applyZen } = require('../lib/zen-apply');
5
+ const { workspaceZen } = require('../lib/zen-workspace');
6
+
7
+ function showZenHelp() {
8
+ console.log('usage: atris zen [--check] [--no-fetch] [--json]');
9
+ console.log('');
10
+ console.log('Brings this repo to zen: code matches origin, nothing left lying around.');
11
+ console.log('Old stashes are archived, idle side copies put away, finished branches');
12
+ console.log('cleared, stray code edits saved to a zen-backup/ branch, and the main');
13
+ console.log('branch caught up. Only branches already on origin are cleared, so nothing');
14
+ console.log('is lost, and everything else can be brought back. Team notes');
15
+ console.log('in atris/ and agent config stay as they are. Anything touched in the last');
16
+ console.log('30 minutes is left alone. A side copy with unsaved work nobody has touched in');
17
+ console.log('3 days is saved to a zen-backup/ branch and put away. A side copy locked with git worktree lock');
18
+ console.log('always stays. Then the workspace is tidied with atris clean, and');
19
+ console.log('anything only you can do is listed with the one command that does it.');
20
+ console.log('');
21
+ console.log(' --check Only look. Report what is out of place and change nothing.');
22
+ console.log(' --no-fetch Use the last fetched origin instead of fetching.');
23
+ console.log(' --json Print machine-readable results.');
24
+ console.log('');
25
+ console.log('Add more team-state paths in .atris/zen.json: {"team_state": ["logs/"]}');
26
+ }
27
+
28
+ function pause(ms) {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ }
31
+
32
+ function makeOut(calm) {
33
+ const dim = (text) => (calm ? `\x1b[2m${text}\x1b[0m` : text);
34
+ const line = (text = '', ms = 110) => {
35
+ console.log(text);
36
+ if (calm && text) pause(ms);
37
+ };
38
+ // One raked line, drawn left to right.
39
+ const rake = (width = 34) => {
40
+ if (!calm) {
41
+ console.log(` ${'─'.repeat(width)}`);
42
+ return;
43
+ }
44
+ process.stdout.write(' ');
45
+ for (let i = 0; i < width; i += 1) {
46
+ process.stdout.write(dim('─'));
47
+ pause(14);
48
+ }
49
+ process.stdout.write('\n');
50
+ pause(160);
51
+ };
52
+ return { dim, line, rake };
53
+ }
54
+
55
+ function teamLine(n) {
56
+ if (!n) return 'no team notes changed.';
57
+ return n === 1 ? '1 team note kept as it is.' : `${n} team notes kept as they are.`;
58
+ }
59
+
60
+ function printYours(yours, line, dim) {
61
+ const width = Math.max(...yours.map((item) => item.text.length));
62
+ for (const item of yours) line(` ${item.text.padEnd(width)} ${dim(item.cmd)}`);
63
+ }
64
+
65
+ function printCheck(result, workspace) {
66
+ const kept = result.team_files
67
+ ? (result.team_files === 1 ? '1 team file kept as it is' : `${result.team_files} team files kept as they are`)
68
+ : 'no team files changed';
69
+ const stale = result.fetched === false ? ' (could not reach origin, using last fetch)' : '';
70
+ const tidy = workspace?.todo.length || 0;
71
+ if (result.zen && tidy) {
72
+ console.log(`zen: almost, the code is calm and the workspace has ${tidy} ${tidy === 1 ? 'thing' : 'things'} to tidy`);
73
+ console.log(` code matches ${result.upstream}${stale}`);
74
+ console.log(` ${kept}`);
75
+ console.log(' run atris zen to make it zen');
76
+ } else if (result.zen) {
77
+ console.log('zen: yes');
78
+ console.log(` code matches ${result.upstream}${stale}`);
79
+ console.log(` ${kept}`);
80
+ } else {
81
+ const n = result.problems.length;
82
+ console.log(`zen: no, ${n} ${n === 1 ? 'thing is' : 'things are'} out of place${stale}`);
83
+ for (const problem of result.problems) console.log(` - ${problem.text}`);
84
+ console.log(` ${kept}`);
85
+ console.log(' run atris zen to make it zen');
86
+ }
87
+ if (result.worktrees_with_work.length) {
88
+ for (const text of sideCopyLines(result.worktrees_with_work, abandonedDaysFromEnv())) console.log(` ${text}`);
89
+ }
90
+ const held = result.worktrees_locked?.length || 0;
91
+ if (held) {
92
+ console.log(` ${held} side ${held === 1 ? 'copy is locked by its owner' : 'copies are locked by their owners'}, left alone`);
93
+ }
94
+ const live = result.worktrees_in_use?.length || 0;
95
+ if (live) {
96
+ const mins = result.fresh_minutes;
97
+ console.log(` ${live} side ${live === 1 ? 'copy was' : 'copies were'} used in the last ${mins} ${mins === 1 ? 'minute' : 'minutes'}, left alone`);
98
+ }
99
+ if (workspace?.todo.length) {
100
+ console.log('workspace:');
101
+ for (const text of workspace.todo) console.log(` - ${text}`);
102
+ }
103
+ if (workspace?.yours.length) {
104
+ console.log('for you:');
105
+ printYours(workspace.yours, (text) => console.log(text), (text) => text);
106
+ }
107
+ }
108
+
109
+ function printApply(result, out) {
110
+ const { dim, line, rake } = out;
111
+ line('');
112
+ line(' atris zen', 200);
113
+ line('');
114
+ rake();
115
+ line('');
116
+ line(dim(' code'));
117
+ if (!result.done.length && result.zen) line(` matches ${result.upstream || 'origin'}`);
118
+ for (const text of result.done) line(` ${text}`);
119
+ for (const text of [...result.left, ...result.notes]) line(dim(` ${text}`));
120
+ line('');
121
+ const ws = result.workspace || { done: [], yours: [] };
122
+ if (ws.ran) {
123
+ line(dim(' workspace'));
124
+ if (!ws.done.length) line(' tidy');
125
+ for (const text of ws.done) line(` ${text}`);
126
+ line('');
127
+ }
128
+ if (ws.yours?.length) {
129
+ line(dim(' for you'));
130
+ printYours(ws.yours, line, dim);
131
+ line('');
132
+ }
133
+ if (result.zen) {
134
+ line(` ${result.already ? 'already zen.' : 'zen.'}`, 260);
135
+ } else {
136
+ line(' almost zen. still out of place:');
137
+ for (const text of result.remaining) line(dim(` - ${text}`));
138
+ }
139
+ line(dim(` ${teamLine(result.team_files)}`));
140
+ if (result.saved.length) {
141
+ line(dim(' everything is kept: git for-each-ref refs/archive refs/heads/zen-backup'));
142
+ }
143
+ line('');
144
+ }
145
+
146
+ function zenCommand(args = [], cwd = process.cwd()) {
147
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
148
+ showZenHelp();
149
+ return 0;
150
+ }
151
+ const fetch = !args.includes('--no-fetch');
152
+ const json = args.includes('--json');
153
+
154
+ if (args.includes('--check')) {
155
+ const result = checkZen({ cwd, fetch });
156
+ if (json) {
157
+ console.log(JSON.stringify(result, null, 2));
158
+ return result.ok ? (result.zen ? 0 : 1) : 2;
159
+ }
160
+ if (!result.ok) {
161
+ console.log(`zen: can't check, ${result.error}.`);
162
+ return 2;
163
+ }
164
+ const workspace = workspaceZen({ root: result.root, dryRun: true });
165
+ printCheck(result, workspace);
166
+ return result.zen && !workspace.todo.length ? 0 : 1;
167
+ }
168
+
169
+ const result = applyZen({ cwd, fetch });
170
+ if (json) {
171
+ console.log(JSON.stringify(result, null, 2));
172
+ return result.ok ? (result.zen ? 0 : 1) : 2;
173
+ }
174
+ if (!result.ok) {
175
+ console.log(`zen: can't start, ${result.error}.`);
176
+ return 2;
177
+ }
178
+ const calm = Boolean(process.stdout.isTTY) && !process.env.CI;
179
+ printApply(result, makeOut(calm));
180
+ return result.zen ? 0 : 1;
181
+ }
182
+
183
+ module.exports = { zenCommand, showZenHelp };
@@ -1157,8 +1157,13 @@ function agentBranchOnlyResult(ref, resolved) {
1157
1157
  };
1158
1158
  }
1159
1159
 
1160
- function runResolvedVerify(resolved, workspaceRoot, cache) {
1161
- const result = runVerifyCommandCached(resolved.verify, workspaceRoot, cache);
1160
+ // A check recorded from main runs against the job's own code, the same place
1161
+ // the second check ran it (lib/job-code-verify.js), so the landing gate never
1162
+ // judges a branch-only test by the main folder.
1163
+ function runResolvedVerify(resolved, task, workspaceRoot, cache) {
1164
+ const result = resolved.rewritten
1165
+ ? runVerifyCommandCached(resolved.verify, workspaceRoot, cache)
1166
+ : require('./job-code-verify').runVerifyAtJobCode(resolved.verify, task, { root: workspaceRoot, cache });
1162
1167
  if (resolved.rewritten && result.reason === 'verify_failed') {
1163
1168
  return { ...result, reason: 'verify_failed_on_main', recorded_verify: resolved.recorded };
1164
1169
  }
@@ -1258,7 +1263,7 @@ function evaluateAutoAccept(task, options = {}) {
1258
1263
  if (tierRequiresStrictVerify && !verify) return strictVerifyMissingResult(ref);
1259
1264
  if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1260
1265
  if (verify && executeVerify) {
1261
- const verifyResult = runResolvedVerify(resolvedVerify, task.workspace_root || process.cwd(), verifyCache);
1266
+ const verifyResult = runResolvedVerify(resolvedVerify, task, task.workspace_root || process.cwd(), verifyCache);
1262
1267
  // A check that runs and fails blocks. So does a check whose worktree
1263
1268
  // is gone, otherwise the daily reap converts "has a failing check"
1264
1269
  // into "lands unchecked" the morning after it clears the worktree.
@@ -1361,7 +1366,7 @@ function evaluateAutoAccept(task, options = {}) {
1361
1366
  if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1362
1367
  if (executeVerify) {
1363
1368
  const workspaceRoot = task.workspace_root || process.cwd();
1364
- const verifyResult = runResolvedVerify(resolvedVerify, workspaceRoot, verifyCache);
1369
+ const verifyResult = runResolvedVerify(resolvedVerify, task, workspaceRoot, verifyCache);
1365
1370
  if (!verifyResult.ok) {
1366
1371
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
1367
1372
  }
@@ -11,6 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { paysDollars } = require('./campaign-core');
13
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
14
+ const { readingKey } = require('./campaign-loop');
14
15
 
15
16
  const LESSON_LINES_FOR_PLANNING = 30;
16
17
 
@@ -26,18 +27,45 @@ function lessonsPath(root, campaignId) {
26
27
  return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
27
28
  }
28
29
 
29
- // The newest lessons, for the planner.
30
- function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
30
+ function globalLessonsPath(root) {
31
+ return path.join(root, 'atris', 'campaigns', 'lessons.md');
32
+ }
33
+
34
+ function parseBulletLines(filePath) {
31
35
  try {
32
- const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
33
- return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
36
+ const text = fs.readFileSync(filePath, 'utf8');
37
+ return text.split('\n').filter((line) => {
38
+ const bullet = line.trim();
39
+ return bullet.startsWith('- ') && bullet !== '- (no review written this round)';
40
+ });
34
41
  } catch {
35
- return '';
42
+ return [];
43
+ }
44
+ }
45
+
46
+ // The newest lessons for the planner: local campaign lessons plus deduplicated
47
+ // cross-campaign RSI lessons so every new campaign inherits earlier lessons.
48
+ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
49
+ const local = parseBulletLines(lessonsPath(root, campaignId));
50
+ const global = parseBulletLines(globalLessonsPath(root));
51
+ const seen = new Set();
52
+ const merged = [];
53
+ for (const line of [...local, ...global]) {
54
+ const key = line.trim().toLowerCase();
55
+ if (seen.has(key)) continue;
56
+ seen.add(key);
57
+ merged.push(line);
36
58
  }
59
+ return merged.slice(-lines).join('\n');
37
60
  }
38
61
 
39
62
  function roundSummary(before, after) {
40
- const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
63
+ const previous = new Map();
64
+ for (const { id, status } of before.missions) {
65
+ if (!previous.has(id)) previous.set(id, new Set());
66
+ previous.get(id).add(status);
67
+ }
68
+ const fresh = after.missions.filter((m) => !previous.get(m.id)?.has(m.status));
41
69
  const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
42
70
  const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
43
71
  const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
@@ -45,6 +73,12 @@ function roundSummary(before, after) {
45
73
  return { kept, failed, vetoed, spent, fresh };
46
74
  }
47
75
 
76
+ // Nothing finished and the check reads the same: there is nothing to learn.
77
+ function quietRound(before, after, summary) {
78
+ const key = (v) => (v.last_judge ? readingKey(v.last_judge.passed, v.last_judge.reading, v.answer.text) : null);
79
+ return !summary.fresh.some((m) => m.status === 'ended' || m.status === 'vetoed') && key(before) === key(after);
80
+ }
81
+
48
82
  // On a subscription the round is told without dollars, since none were paid.
49
83
  function roundTally(view, summary) {
50
84
  return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
@@ -82,9 +116,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
82
116
  async function review(id, before) {
83
117
  const after = getCampaign(store, id);
84
118
  const summary = roundSummary(before, after);
119
+ const quiet = quietRound(before, after, summary);
85
120
  const lessons = readLessons(root, id, 200);
86
121
  let written = [];
87
- if (ask) {
122
+ if (ask && !quiet) {
88
123
  const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
89
124
  // A lesson already on file is not written twice.
90
125
  const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
@@ -96,8 +131,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
96
131
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
97
132
  const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
98
133
  const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
99
- fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
100
- return { summary, lessons: written, view: after };
134
+ // A quiet round is noted as plain text, so the planner never reads it as a lesson.
135
+ const body = quiet ? ['(no review written this round: nothing ended and the reading held)'] : (written.length ? written : ['- (no review written this round)']);
136
+ fs.appendFileSync(file, `${header}${body.join('\n')}\n`);
137
+ return { summary, lessons: written, view: after, quiet };
101
138
  }
102
139
 
103
140
  async function run(id) {
@@ -109,27 +146,29 @@ function makeCadence(root, loop, options = {}, deps = {}) {
109
146
  const until = now() + workMs;
110
147
  log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
111
148
  let stopped = null;
149
+ let failed = false;
112
150
  while (now() < until) {
113
151
  const result = await loop.step(id);
114
152
  if (result.stop) {
115
153
  stopped = result.reason;
154
+ failed = result.ok === false;
116
155
  break;
117
156
  }
118
157
  }
119
158
  const breakStarted = now();
120
159
  log(`round ${round}: review`);
121
- const { summary, lessons, view: reviewed } = await review(id, before);
122
- log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
160
+ const { summary, lessons, view: reviewed, quiet } = await review(id, before);
161
+ log(quiet ? ' nothing new this round; no review' : ` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
123
162
  const view = getCampaign(store, id);
124
- last = { reason: stopped || 'work window ended', round };
163
+ last = { reason: stopped || 'work window ended', round, failed };
125
164
  // Finished, out of money, or waiting on a person: no point in another round.
126
165
  if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
127
- return { rounds: round, reason: stopped || view.state, campaign: view };
166
+ return { ...(failed ? { ok: false } : {}), rounds: round, reason: stopped || view.state, campaign: view };
128
167
  }
129
168
  const rest = breakMs - (now() - breakStarted);
130
169
  if (round < rounds && rest > 0) await sleep(rest);
131
170
  }
132
- return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
171
+ return { ...(last.failed ? { ok: false } : {}), rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
133
172
  }
134
173
 
135
174
  return { run, review };