atris 3.70.0 → 3.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +47 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
package/lib/fleet.js CHANGED
@@ -27,6 +27,7 @@ const { setEngineHealth, recordEngineRunHealth, engineStallReason, engineFailure
27
27
  const { appendRosterAttempts, parseRunUsage } = require('./roster-runs');
28
28
  const { engineRunsView } = require('./roster-models');
29
29
  const { resolveDefaultVerifier } = require('./default-verifier');
30
+ const { taskCheck } = require('./member-task-pick');
30
31
  const { rankEnginesDetailed } = require('./router-brain');
31
32
  const {
32
33
  buildOneLapValidatorPrompt,
@@ -50,6 +51,7 @@ const {
50
51
  PROXY_ENV_KEYS: SECRET_GATEWAY_PROXY_ENV_KEYS,
51
52
  } = require('./secret-gateway');
52
53
  const { appendEngineLiveLogChunk, createEngineLiveLog, engineTerminalReason } = require('./engine-job-lifecycle');
54
+ const { recordEngineResource } = require('./engine-resource');
53
55
 
54
56
  // Lanes a fleet may never staff on its own: the human keeps irreversible
55
57
  // calls. Mirrors the autoland denied lanes.
@@ -86,28 +88,50 @@ const METHOD_KERNEL = [
86
88
  const ATRIS_BUILD_PROCESS_PREAMBLE = [
87
89
  'you are set up to do this well.',
88
90
  'use the atris process:',
89
- 'claim the task, read the map, and work only the named contract.',
91
+ 'this task is already claimed for you and its record is kept for you.',
92
+ 'run no atris command except `atris worktree guard` and the landing step below; skip any boot step the repo agent files ask for.',
93
+ 'read the map, and work only the named contract.',
90
94
  'run every gate bare and read its real exit code.',
91
95
  'report honestly, including failures, and stop at the named stop point.',
92
96
  'if a git hook blocks your commit, stop and report it; never use --no-verify.',
93
97
  ].join('\n');
94
98
 
95
- // The bounded prompt every engine gets. Same contract the manual flight used:
96
- // isolated worktree, commit never push, MAP first, focused verify, report.
99
+ // The check a dispatched task must pass, best source first: the task's own
100
+ // verify field (`atris task new --verify`), then a "Check:" written in the
101
+ // title, then the repo's default. The verify field used to be ignored, so a
102
+ // task that named its check got `git diff --check` instead.
103
+ function taskDispatchCheck(task, root = process.cwd()) {
104
+ const own = taskCheck(task).command;
105
+ if (own) return own;
106
+ const { check } = parseDoneCheck(task && task.title);
107
+ return check || resolveDefaultVerifier(root);
108
+ }
109
+
110
+ // One shell word, so a check with quotes or spaces survives being pasted
111
+ // into `atris worktree ship --verify <check>`.
112
+ function shellQuote(value) {
113
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
114
+ }
115
+
116
+ // The bounded prompt every engine gets: isolated worktree, MAP first, the
117
+ // task's own check, one landing order per mode. Yolo: the engine lands it
118
+ // itself with worktree ship. Otherwise: commit only, the conductor lands it.
97
119
  function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
98
120
  const ref = task.display_id || task.id || 'TASK';
99
121
  const title = String(task.title || '').trim();
100
- const { done, check: declaredCheck } = parseDoneCheck(title);
101
- const check = declaredCheck || resolveDefaultVerifier(worktreePath || process.cwd());
122
+ const { done: titleDone } = parseDoneCheck(title);
123
+ const done = titleDone || String((task.metadata && task.metadata.done_looks_like) || '').trim();
124
+ const check = taskDispatchCheck(task, worktreePath || process.cwd());
125
+ const shipMessage = title.split(/[.:](?:\s|$)/)[0].replace(/"/g, "'").slice(0, 90) || ref;
102
126
  const commitRule = yolo
103
- ? 'Commit on the current branch with a plain-English message, then land it yourself: run atris worktree ship --message "<msg>" --verify "npm run test:fast && node --test <focused files>" --merge and report the PR URL. If ship reports a rebase conflict or the verify fails, stop and report; never resolve conflicts yourself.'
104
- : 'Commit on the current branch with a clear message. Do not push. Do not create branches.';
127
+ ? `Land it yourself. Commit on the current branch with a plain-English message, then run: atris worktree ship --message "${shipMessage} (${ref})" --verify ${shellQuote(check)} --merge, and report the PR URL. That ship command is the only push allowed. If it reports a conflict or a failed check, stop and report; never resolve conflicts yourself.`
128
+ : 'Commit on the current branch with a clear message. Do not push and do not create branches; the conductor lands your commit.';
105
129
  const lines = [
106
130
  'First, run `atris worktree guard`; if it fails, stop immediately, report back, and do not edit anything. Do this before any file edit.',
107
131
  '',
108
132
  ATRIS_BUILD_PROCESS_PREAMBLE,
109
133
  '',
110
- `You are working task ${ref} in this repo checkout (an isolated git worktree${worktreePath ? ` at ${worktreePath}` : ''}, commit here, NEVER push).`,
134
+ `You are working task ${ref} in its own git worktree${worktreePath ? ` at ${worktreePath}` : ''}.`,
111
135
  '',
112
136
  `Task: ${title}`,
113
137
  '',
@@ -121,11 +145,13 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
121
145
  : '- Read atris/MAP.md first to locate the code; never guess file locations.',
122
146
  ...SHARED_BRIEF_RULES,
123
147
  '- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
124
- '- Include or update a focused regression test; run it with npm run test:fast && node --test <focused files> before committing.',
148
+ '- Never stage or commit anything under .atris/ or atris/TODO.md; atris rewrites those itself and they clash between parallel tasks.',
149
+ '- If the task changes how code behaves, add or update a focused regression test for it. A text, docs, or config edit needs no new test.',
150
+ `- Run the check bare before committing: ${check}`,
125
151
  `- ${commitRule}`,
126
152
  ...METHOD_KERNEL.map((rule) => `- ${rule}`),
127
153
  '',
128
- 'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
154
+ `Final report (plain text): files changed, check command + result, commit sha${yolo ? ', PR URL' : ''} (or say what failed and why).`
129
155
  );
130
156
  const lessonFiles = [
131
157
  ...(Array.isArray(task.files) ? task.files : []),
@@ -1248,8 +1274,32 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
1248
1274
  if (!deferredCleanup) cleanup();
1249
1275
  }
1250
1276
  });
1251
- const result = exec(command, { timeoutMs: dispatchTimeoutMs });
1277
+ // Machine memory and load at both ends of the run, so a SIGKILL that leaves
1278
+ // no report of its own still says whether the machine was short on memory.
1279
+ const resourceTask = dispatchTaskId(task);
1280
+ const resourceRunId = crypto.randomUUID();
1281
+ recordEngineResource(root, { phase: 'start', engine, task: resourceTask, run_id: resourceRunId });
1282
+ const resourceStartedAt = process.hrtime.bigint();
1283
+ const recordResourceEnd = (run) => recordEngineResource(root, {
1284
+ phase: 'end', engine, task: resourceTask, run_id: resourceRunId,
1285
+ signal: dispatchResultSignal(run),
1286
+ timed_out: Boolean(run && run.error && run.error.code === 'ETIMEDOUT'),
1287
+ // Why a run failed to start, whether the runner threw it or returned it.
1288
+ error: run instanceof Error ? run.message : (run && run.error instanceof Error ? run.error.message : ''),
1289
+ duration_ms: Math.round(Number(process.hrtime.bigint() - resourceStartedAt) / 1e6),
1290
+ });
1291
+ const runnerFailed = (error) => {
1292
+ recordResourceEnd(error);
1293
+ throw error;
1294
+ };
1295
+ let result;
1296
+ try {
1297
+ result = exec(command, { timeoutMs: dispatchTimeoutMs });
1298
+ } catch (error) {
1299
+ return runnerFailed(error);
1300
+ }
1252
1301
  const toDispatch = (run) => {
1302
+ const resourceEnd = recordResourceEnd(run);
1253
1303
  let watchdogReceipt = null;
1254
1304
  if (watchdogArtifact && fs.existsSync(watchdogArtifact)) {
1255
1305
  try { watchdogReceipt = JSON.parse(fs.readFileSync(watchdogArtifact, 'utf8')); } catch {}
@@ -1277,11 +1327,12 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
1277
1327
  cancelled: Boolean(run.cancelled),
1278
1328
  report: stdout.slice(-8000),
1279
1329
  stderr: String(run.stderr || '').slice(-2000),
1330
+ ...(resourceEnd.cause ? { kill_cause: resourceEnd.cause } : {}),
1280
1331
  ...(unwrapped && unwrapped.usage ? { usage: unwrapped.usage } : {}),
1281
1332
  ...(unwrapped && unwrapped.cost_usd !== undefined ? { cost_usd: unwrapped.cost_usd } : {}),
1282
1333
  };
1283
1334
  };
1284
- if (result && typeof result.then === 'function') return result.then(toDispatch);
1335
+ if (result && typeof result.then === 'function') return result.then(toDispatch, runnerFailed);
1285
1336
  return toDispatch(result);
1286
1337
  }
1287
1338
 
@@ -1403,6 +1454,19 @@ function dispatchResultToTerminal(entry = {}, result = {}) {
1403
1454
  // always surfaces the missing-module line from either stream instead of
1404
1455
  // letting head/tail clipping truncate it away (cost measured live: CLI-1185
1405
1456
  // receipts kept only a loader stack tail and the module name was lost).
1457
+ // True when the worktree's current branch already exists on origin.
1458
+ function branchOnOrigin(worktreePath) {
1459
+ const branch = spawnSync('git', ['-C', worktreePath, 'symbolic-ref', '--quiet', '--short', 'HEAD'], { encoding: 'utf8' });
1460
+ const name = String(branch.stdout || '').trim();
1461
+ if (branch.status !== 0 || !name) return false;
1462
+ const remote = spawnSync('git', ['-C', worktreePath, 'ls-remote', '--exit-code', '--heads', 'origin', name], {
1463
+ encoding: 'utf8',
1464
+ env: gitNetworkEnvironment(process.env, worktreePath),
1465
+ timeout: 15000,
1466
+ });
1467
+ return remote.status === 0;
1468
+ }
1469
+
1406
1470
  function shipWithRetry(cli, args, cwd) {
1407
1471
  let shipped = cli(args, cwd);
1408
1472
  if (shipped.status !== 0 && /MODULE_NOT_FOUND|Cannot find module/.test(`${shipped.stderr}${shipped.stdout}`)) {
@@ -1603,6 +1667,7 @@ module.exports = {
1603
1667
  clipHeadTail,
1604
1668
  guardGlobalCliLink,
1605
1669
  defaultSelfLandCheck,
1670
+ defaultDispatchStartCommit,
1606
1671
  };
1607
1672
 
1608
1673
  // ---------------------------------------------------------------------------
@@ -2867,6 +2932,8 @@ function focusedCheck(task) {
2867
2932
  // does not apply: fall back to the task's whole Check: text so the operator's
2868
2933
  // actual check command is what gets re-run as verification.
2869
2934
  function dispatchCheck(task) {
2935
+ const own = taskCheck(task).command;
2936
+ if (own) return own;
2870
2937
  const narrow = focusedCheck(task);
2871
2938
  if (narrow) return narrow;
2872
2939
  const { check } = parseDoneCheck(task.title);
@@ -2947,8 +3014,19 @@ function defaultTrustedVerifyRunner(command, cwd) {
2947
3014
  };
2948
3015
  }
2949
3016
 
3017
+ // A retry's worktree starts on the last run's unlanded commits. Measuring
3018
+ // from that HEAD made a retry that landed those commits read "no work
3019
+ // landed", so a resumed worktree measures from where it left the mainline.
2950
3020
  function defaultDispatchStartCommit({ worktreePath, git = null } = {}) {
2951
3021
  const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
3022
+ let sidecar = null;
3023
+ try {
3024
+ sidecar = JSON.parse(fs.readFileSync(path.join(worktreePath, '.atris', 'agent-worktree.json'), 'utf8'));
3025
+ } catch {}
3026
+ if (sidecar && sidecar.resumed_from && sidecar.checkout_base) {
3027
+ const base = run(['merge-base', 'HEAD', sidecar.checkout_base]);
3028
+ if (base && base.status === 0 && String(base.stdout || '').trim()) return String(base.stdout).trim();
3029
+ }
2952
3030
  const head = run(['rev-parse', '--verify', 'HEAD^{commit}']);
2953
3031
  if (!head || head.status !== 0) return '';
2954
3032
  return String(head.stdout || '').trim();
@@ -3278,7 +3356,12 @@ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
3278
3356
  // paused task whose worktree holds no commits past base and nothing but
3279
3357
  // conductor litter gets its claim released; a paused task WITH work keeps the
3280
3358
  // claim so takeover stays attributed.
3281
- function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, baseRef = '', log = () => {} }) {
3359
+ //
3360
+ // keepClaimsWithWork=false is for one-shot dispatch: when its flight ends
3361
+ // nothing is working the task any more, and a kept claim made the retry
3362
+ // fail at "claim failed". The work stays on its branch and the retry's
3363
+ // worktree start resumes from it.
3364
+ function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
3282
3365
  for (const row of paused || []) {
3283
3366
  if (row.stage === 'claim' || row.stage === 'task_lookup' || row.landed === true) continue; // no claim, or work already landed
3284
3367
  if (row.claim_released !== undefined) continue;
@@ -3286,6 +3369,12 @@ function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTa
3286
3369
  const actor = actorByTask.get(taskId);
3287
3370
  if (!taskId || !actor) continue;
3288
3371
  const worktreePath = row.worktree || worktreeByTask.get(taskId) || '';
3372
+ if (!keepClaimsWithWork) {
3373
+ const released = cli(['task', 'release', taskId, '--as', actor]);
3374
+ row.claim_released = Boolean(released && released.status === 0);
3375
+ if (row.claim_released) log(` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
3376
+ continue;
3377
+ }
3289
3378
  if (worktreePath && fs.existsSync(worktreePath)) {
3290
3379
  const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
3291
3380
  if (!baseRef && resolvedBase === 'HEAD') continue; // no known start point: preserve the claim
@@ -3438,7 +3527,11 @@ async function runDispatchFlight({
3438
3527
  if (enforceRemoteBoundary) {
3439
3528
  remoteBoundary = prepareReviewSandbox({ root, taskId, engine, baseRef: landTarget });
3440
3529
  } else {
3441
- const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
3530
+ // The claim just taken is the lock against a second live flight, so an
3531
+ // earlier finished run's branch must not refuse the retry. Worktree
3532
+ // start resumes that branch's commits, or starts fresh when they no
3533
+ // longer merge cleanly (the member-path retry fix).
3534
+ const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, '--force', ...startBaseArgs]);
3442
3535
  const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
3443
3536
  if (!wt) {
3444
3537
  const released = releaseUnstartedDispatchClaim(cli, {
@@ -3486,6 +3579,9 @@ async function runDispatchFlight({
3486
3579
  remoteBoundary,
3487
3580
  remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
3488
3581
  startCommit,
3582
+ // The attempt loop rebuilds the brief from this flag. Without it every
3583
+ // yolo engine got the commit-only brief and could never land.
3584
+ yolo: yolo && !localOnly,
3489
3585
  ...(maxSeconds ? { maxSeconds } : {}),
3490
3586
  ...(trustedPrompt ? { prompt: trustedPrompt } : {}),
3491
3587
  });
@@ -3583,7 +3679,7 @@ async function runDispatchFlight({
3583
3679
  const rebaseArrival = rebase || landArrival;
3584
3680
  const checkSelfLand = selfLandCheck || defaultSelfLandCheck;
3585
3681
  const land = lander || (({ entry }) => {
3586
- const rebased = enforceRemoteBoundary || localOnly
3682
+ const rebased = enforceRemoteBoundary || localOnly || entry.alreadyPushed
3587
3683
  ? { ok: true, stage: 'frozen_base' }
3588
3684
  : rebaseArrival({ worktreePath: entry.worktreePath, targetRef: landTarget });
3589
3685
  if (!rebased.ok) return rebased;
@@ -3740,6 +3836,18 @@ async function runDispatchFlight({
3740
3836
  });
3741
3837
  stampDispatchBrief(root, result.brief_id, 'pass', `self-landed ${entry.taskId}`);
3742
3838
  log(` ✓ self-landed ${entry.taskId}`);
3839
+ continue;
3840
+ }
3841
+ // The engine committed real work but did not land it (no ship run, or
3842
+ // ship failed on its own). Land it here the same way a plain dispatch
3843
+ // does: rebase, re-run the task's check, ship. A conflict or failed
3844
+ // check still pauses below.
3845
+ if (selfLanded.stage === 'self_land_missing') {
3846
+ // An engine whose own ship pushed but failed to merge left a PR open.
3847
+ // Rebasing would make that branch impossible to push again, so ship
3848
+ // the pushed branch as it is; ship's merge check still stops a clash.
3849
+ landingEntry.alreadyPushed = branchOnOrigin(entry.worktreePath);
3850
+ log(` ${entry.taskId} is committed but not landed; landing it now`);
3743
3851
  } else {
3744
3852
  const stage = selfLanded.stage || 'self_land_missing';
3745
3853
  flight.paused.push({
@@ -3755,8 +3863,8 @@ async function runDispatchFlight({
3755
3863
  log(stage === 'no_work_landed'
3756
3864
  ? ` ⏸ no work landed for ${entry.taskId}`
3757
3865
  : ` ⏸ paused ${entry.taskId} at ${stage}`);
3866
+ continue;
3758
3867
  }
3759
- continue;
3760
3868
  }
3761
3869
  log(` ${proofOnly ? 'checking' : 'landing'} ${entry.taskId}...`);
3762
3870
  const landed = land({ entry: landingEntry, result });
@@ -4070,6 +4178,7 @@ async function runDispatchFlight({
4070
4178
  actorByTask: new Map(ids.map((taskId) => [String(taskId), taskActor])),
4071
4179
  baseRef: landTarget,
4072
4180
  log,
4181
+ keepClaimsWithWork: proofOnly,
4073
4182
  });
4074
4183
 
4075
4184
  recordFlightRosterRuns(root, flight, results, {
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { runGit } = require('./git-spawn');
5
+
6
+ // Member work copies are cut from committed history, so in a brand new repo
7
+ // with no commits yet there is nothing for a member's first run to start from.
8
+ // Init snapshots what was already dirty, then commits only the paths it touched
9
+ // itself. The person's own uncommitted work and anything they had staged stay
10
+ // exactly as they were. A repo that already has history is left alone: a
11
+ // surprise commit on someone's branch is theirs to make, and members cut from
12
+ // the shared mainline would not see it until it lands there anyway.
13
+
14
+ const FALLBACK_IDENTITY = ['-c', 'user.name=Atris', '-c', 'user.email=299057014+atris-builder[bot]@users.noreply.github.com'];
15
+
16
+ function repoRootFor(cwd) {
17
+ const result = runGit(['rev-parse', '--show-toplevel'], { cwd, check: false });
18
+ if (result.status !== 0) return null;
19
+ return result.stdout.trim() || null;
20
+ }
21
+
22
+ // Paths git reports as changed or untracked, relative to the repo root.
23
+ function dirtyPaths(root) {
24
+ const result = runGit(['status', '--porcelain', '-z', '--untracked-files=all'], { cwd: root, check: false, maxBuffer: 64 * 1024 * 1024 });
25
+ if (result.status !== 0) return null;
26
+ const entries = result.stdout.split('\0');
27
+ const paths = new Set();
28
+ for (let i = 0; i < entries.length; i++) {
29
+ const entry = entries[i];
30
+ if (!entry) continue;
31
+ const code = entry.slice(0, 2);
32
+ paths.add(entry.slice(3));
33
+ // Renames and copies carry the old path as the next entry.
34
+ if (code[0] === 'R' || code[0] === 'C') i++;
35
+ }
36
+ return paths;
37
+ }
38
+
39
+ // Machine-local config (for example .devin/config.local.json) never belongs in history.
40
+ function isLocalOnly(relPath) {
41
+ return /\.local(\.|$)/.test(path.basename(relPath));
42
+ }
43
+
44
+ function hasCommits(root) {
45
+ return runGit(['rev-parse', '--verify', '-q', 'HEAD'], { cwd: root, check: false }).status === 0;
46
+ }
47
+
48
+ function snapshotBeforeInit(cwd = process.cwd()) {
49
+ const root = repoRootFor(cwd);
50
+ if (!root) return null;
51
+ const before = dirtyPaths(root);
52
+ if (!before) return null;
53
+ return { root, before, fresh: !hasCommits(root) };
54
+ }
55
+
56
+ function commitInitFiles(snapshot, { message = 'Set up Atris' } = {}) {
57
+ if (!snapshot) return { committed: false, reason: 'not_git' };
58
+ if (!snapshot.fresh) return { committed: false, reason: 'has_history' };
59
+ const after = dirtyPaths(snapshot.root);
60
+ if (!after) return { committed: false, reason: 'status_failed' };
61
+ const paths = [...after].filter((p) => !snapshot.before.has(p) && !isLocalOnly(p)).sort();
62
+ if (paths.length === 0) return { committed: false, reason: 'nothing_new', paths };
63
+
64
+ const add = runGit(['add', '--', ...paths], { cwd: snapshot.root, check: false });
65
+ if (add.status !== 0) return { committed: false, reason: 'add_failed', detail: (add.stderr || '').trim(), paths };
66
+
67
+ const commitArgs = ['commit', '-q', '-m', message, '--only', '--', ...paths];
68
+ let commit = runGit(commitArgs, { cwd: snapshot.root, check: false });
69
+ if (commit.status !== 0 && /tell me who you are|unable to auto-detect email/i.test(commit.stderr || '')) {
70
+ commit = runGit([...FALLBACK_IDENTITY, ...commitArgs], { cwd: snapshot.root, check: false });
71
+ }
72
+ if (commit.status !== 0) {
73
+ // Leave the index as we found it so a blocked commit stays easy to redo by hand.
74
+ runGit(['reset', '-q', '--', ...paths], { cwd: snapshot.root, check: false });
75
+ return { committed: false, reason: 'commit_failed', detail: (commit.stderr || commit.stdout || '').trim(), paths };
76
+ }
77
+ return { committed: true, paths };
78
+ }
79
+
80
+ module.exports = { snapshotBeforeInit, commitInitFiles, dirtyPaths, isLocalOnly };
package/lib/intents.js CHANGED
@@ -60,6 +60,14 @@ const INTENTS = Object.freeze([
60
60
  plain: 'your agent turns the idea into a clear job and starts once any gaps are answered.',
61
61
  offer: 'always',
62
62
  }),
63
+ Object.freeze({
64
+ id: 'run_a_campaign',
65
+ say: Object.freeze(['run a campaign', 'long goal', 'work on this until the check passes', 'big goal with a budget']),
66
+ do: 'atris campaign new "<their words>"',
67
+ plain: 'your agent writes a check that fails today, shows it to you, then works the goal under a budget until the check passes.',
68
+ offer: 'always',
69
+ confirm: true,
70
+ }),
63
71
  Object.freeze({
64
72
  id: 'catch_up',
65
73
  say: Object.freeze(['catch me up', 'what happened', 'what landed', 'show me recent progress']),
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
package/lib/land-green.js CHANGED
@@ -20,6 +20,7 @@ const os = require('os');
20
20
  const path = require('path');
21
21
  const { spawnSync } = require('child_process');
22
22
  const { runGit } = require('./git-spawn');
23
+ const { proveCheckBeforePublish, proveLanding } = require('./landing-proof');
23
24
 
24
25
  const DEFAULT_VERIFY = 'npm test';
25
26
  const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
@@ -239,8 +240,15 @@ function landOneBranch(root, base, branch, {
239
240
  if (!scratch.ok) return verdictFor(branch, base, { action: 'error', reason: 'scratch_checkout_failed', detail: scratch.error });
240
241
  try {
241
242
  // Verify the merged state, not the branch tip: base may have moved since.
243
+ const before = gitOut(['rev-parse', target.target], scratch.dir);
242
244
  const merged = gitOut(['merge', '--no-edit', '--no-ff', target.target, '-m', `land: ${branch} onto ${base} after a green verify`], scratch.dir);
243
245
  if (!merged.ok) return verdictFor(branch, base, { action: 'error', reason: 'merge_failed', detail: merged.err || merged.out });
246
+ // A branch of empty commits (a "Start flight" marker, a change already on
247
+ // base) merges clean and passes every check. Landing it would record a
248
+ // landing over nothing, so it is named a no-op and never pushed.
249
+ if (gitOut(['diff', '--quiet', target.target, 'HEAD'], scratch.dir).ok) {
250
+ return verdictFor(branch, base, { action: 'no_op', reason: 'empty_diff', detail: `${branch} changes nothing on ${base}` });
251
+ }
244
252
  const deps = installDeps(scratch.dir);
245
253
  if (!deps.ok) return verdictFor(branch, base, { action: 'error', reason: 'install_failed', detail: deps.detail });
246
254
  const verify = runVerifyFn(verifyCommand, scratch.dir, { timeoutMs: verifyTimeoutMs, env });
@@ -254,6 +262,18 @@ function landOneBranch(root, base, branch, {
254
262
  }
255
263
  const sha = gitOut(['rev-parse', 'HEAD'], scratch.dir);
256
264
  if (!sha.ok) return verdictFor(branch, base, { action: 'error', reason: 'merged_sha_unreadable' });
265
+ const checkCommand = verify.command || verifyCommand;
266
+ // Nothing is published on a check that could not have caught a break: it
267
+ // must be one that can fail, and it must have run on this merged commit
268
+ // with every changed file committed and tracked. Otherwise hold.
269
+ const unbacked = proveCheckBeforePublish(scratch.dir, {
270
+ commit: sha.out,
271
+ base: before.ok ? before.out : target.target,
272
+ verify: checkCommand,
273
+ });
274
+ if (unbacked) {
275
+ return verdictFor(branch, base, { action: 'skipped', reason: unbacked.reason, detail: unbacked.detail, verify });
276
+ }
257
277
  const local = localBaseName(base);
258
278
  const pushed = push ? pushBase(root, local, sha.out) : { attempted: false, ok: false, reason: 'push_disabled' };
259
279
  if (pushed.attempted && !pushed.ok) {
@@ -262,8 +282,35 @@ function landOneBranch(root, base, branch, {
262
282
  return verdictFor(branch, base, { action: 'skipped', reason: 'push_rejected', detail: pushed.detail, verify });
263
283
  }
264
284
  const advanced = advanceBase(root, local, sha.out);
265
- if (!advanced.ok) return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, local_base: advanced.reason });
266
- return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed });
285
+ // "landed" is a fact about the target branch, checked after the fact by
286
+ // the same rule every receipt uses: the commit must be on origin's copy.
287
+ // A local branch only counts in a repo with no remote at all; with a
288
+ // remote, an unpushed merge is not landed however the local ref moved.
289
+ const noRemote = !gitOut(['remote'], root).out;
290
+ const landing = proveLanding(root, {
291
+ commit: sha.out,
292
+ base: before.ok ? before.out : '',
293
+ target: noRemote ? `refs/heads/${local}` : `refs/remotes/origin/${local}`,
294
+ verify: checkCommand,
295
+ localTargetOk: noRemote,
296
+ });
297
+ if (landing.status !== 'landed') {
298
+ // Once pushed, the merge is on origin whatever the check says; the
299
+ // record names both facts instead of a bare "unverified".
300
+ return verdictFor(branch, base, {
301
+ action: pushed.ok ? 'pushed' : landing.status,
302
+ reason: landing.reason,
303
+ detail: landing.detail,
304
+ landing_check: landing.status,
305
+ sha: sha.out,
306
+ verify,
307
+ push: pushed,
308
+ local_base: advanced.ok ? undefined : advanced.reason,
309
+ landing,
310
+ });
311
+ }
312
+ if (!advanced.ok) return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, local_base: advanced.reason, landing });
313
+ return verdictFor(branch, base, { action: 'landed', sha: sha.out, verify, push: pushed, landing });
267
314
  } finally {
268
315
  dropScratch(root, scratch.dir);
269
316
  }
@@ -311,10 +358,12 @@ function landStaleGreenBranches(root, {
311
358
  .filter((b) => b.state === 'active' && b.stale)
312
359
  .sort((a, b) => b.activityHours - a.activityHours);
313
360
  const verdicts = [];
314
- // Only landed and red verdicts spend the per-tick budget: an erroring or
315
- // ineligible branch must not starve the rest of the queue. A bounded run of
316
- // misses still stops the walk so a fully broken queue does not burn a
317
- // scratch checkout per branch per tick.
361
+ // Any verdict that ran the verify suite spends the per-tick budget, landed
362
+ // or not: a run of pushed-but-unproven or no-landing verdicts must not run
363
+ // four 20-minute suites in one tick. An erroring or ineligible branch that
364
+ // never reached the suite does not starve the rest of the queue. A bounded
365
+ // run of misses still stops the walk so a fully broken queue does not burn
366
+ // a scratch checkout per branch per tick.
318
367
  let spent = 0;
319
368
  let misses = 0;
320
369
  for (const candidate of candidates) {
@@ -328,7 +377,7 @@ function landStaleGreenBranches(root, {
328
377
  } catch (err) {
329
378
  verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
330
379
  }
331
- if (verdict.action === 'landed' || verdict.action === 'red') {
380
+ if (verdict.action === 'landed' || verdict.action === 'red' || verdict.verify) {
332
381
  spent += 1;
333
382
  misses = 0;
334
383
  } else {
@@ -362,6 +411,8 @@ function summaryLine(result) {
362
411
  for (const v of result.verdicts) {
363
412
  if (v.action === 'skipped' && v.reason !== 'per_tick_limit') parts.push(`${v.branch} held (${v.reason.replace(/_/g, ' ')}${v.detail ? `: ${v.detail}` : ''})`);
364
413
  if (v.action === 'error') parts.push(`${v.branch} error (${v.reason.replace(/_/g, ' ')})`);
414
+ if (v.action === 'pushed') parts.push(`${v.branch} pushed, landing check: ${String(v.landing_check).replace(/_/g, ' ')} (${v.reason.replace(/_/g, ' ')})`);
415
+ if (['no_op', 'not_landed', 'unverified'].includes(v.action)) parts.push(`${v.branch} not landed (${v.reason.replace(/_/g, ' ')})`);
365
416
  }
366
417
  const waiting = result.verdicts.filter((v) => v.reason === 'per_tick_limit').length;
367
418
  if (waiting) parts.push(`${waiting} more waiting for the next tick`);