atris 3.69.0 → 3.71.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 (79) hide show
  1. package/README.md +40 -1
  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 +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -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/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -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 +63 -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
@@ -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`);
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ // The one rule for the word "landed".
4
+ //
5
+ // Before this module, "landed" meant whatever the writer had in hand. The
6
+ // autoland receipt called every accepted task landed. On 2026-10-14 the 07:14
7
+ // tick receipt listed AIT-5..AIT-8 as landed; their commits (50282f7c,
8
+ // bcb20291, ...) sat on a worktree branch and never reached origin/master.
9
+ // OBL-3213 "landed" after its verifier ran a test file out of a different
10
+ // worktree (executor-operate) while the repo's own checkout never had the
11
+ // change. Across the 537 auto-accepts in the last 30 days that recorded the
12
+ // commit their check ran on, 72 ran on a commit that never reached the target
13
+ // branch and 137 more ran on a commit older than the task itself, so the check
14
+ // could not have seen the work. 80 more were "verified" by `git diff --check`,
15
+ // which passes on an empty diff.
16
+ //
17
+ // So a receipt may say "landed" only when all of this holds:
18
+ // 1. the work's own commit has a non-empty change from its base, and every
19
+ // file it changed reads the same on origin's target branch right now (or
20
+ // a later edit made after the target carried it). Content, not commit
21
+ // ids: squash, rebase and merge-queue merges rewrite ids. A file the
22
+ // target went back to its old version of is a revert, not a landing;
23
+ // 2. the change touches every tracked file the work claims;
24
+ // 3. the check behind it can fail, and ran on a checkout that held the work
25
+ // with those files committed.
26
+ // Anything else is recorded as what it is: not_landed, no_op, or unverified.
27
+ // A repo with no fetched origin target is unverified: a local branch is not
28
+ // somewhere anyone else can see the work.
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const { runGit } = require('./git-spawn');
33
+ const { nonFalsifyingIssue } = require('./accept-verify-gate');
34
+
35
+ const SHA = /^[0-9a-f]{40}$/;
36
+ const MAX_HISTORY_SCAN = 50;
37
+
38
+ function git(root, args) {
39
+ const result = runGit(args, { cwd: root, check: false });
40
+ return { ok: result.status === 0, out: String(result.stdout || '').trim() };
41
+ }
42
+
43
+ // Answers about fixed commit ids never change, so they are cached for the
44
+ // life of the process. One heartbeat proves hundreds of tasks against the
45
+ // same few commits; without this a 500-task sweep cost ~7,000 git spawns.
46
+ // Anything that reads a ref or the working tree is never cached.
47
+ const immutable = new Map();
48
+ function gitFixed(top, args) {
49
+ const key = `${top}\u0000${args.join('\u0000')}`;
50
+ if (immutable.has(key)) return immutable.get(key);
51
+ const result = git(top, args);
52
+ if (immutable.size > 20000) immutable.clear();
53
+ immutable.set(key, result);
54
+ return result;
55
+ }
56
+
57
+ const toplevels = new Map();
58
+ function toplevel(root) {
59
+ const key = path.resolve(root);
60
+ if (!toplevels.has(key)) {
61
+ const top = git(root, ['rev-parse', '--show-toplevel']);
62
+ toplevels.set(key, top.ok && top.out ? top.out : null);
63
+ }
64
+ return toplevels.get(key);
65
+ }
66
+
67
+ function resolveCommit(top, ref) {
68
+ const text = String(ref || '').trim();
69
+ if (!text) return null;
70
+ const args = ['rev-parse', '--verify', '--quiet', `${text}^{commit}`];
71
+ const result = SHA.test(text) ? gitFixed(top, args) : git(top, args);
72
+ return result.ok && result.out ? result.out : null;
73
+ }
74
+
75
+ function isAncestor(top, older, newer) {
76
+ return gitFixed(top, ['merge-base', '--is-ancestor', older, newer]).ok;
77
+ }
78
+
79
+ function blobAt(top, sha, file) {
80
+ const result = gitFixed(top, ['rev-parse', '--verify', '--quiet', `${sha}:${file}`]);
81
+ return result.ok && result.out ? result.out : null;
82
+ }
83
+
84
+ // Only origin's copy counts. A local branch (refs/heads/*) is only a target
85
+ // when the caller says so: the green lander in a repo with no remote at all.
86
+ function resolveTarget(top, target, { localTargetOk = false } = {}) {
87
+ const name = String(target || '').trim();
88
+ if (name.startsWith('refs/heads/') && !localTargetOk) return null;
89
+ if (name.startsWith('refs/')) return resolveCommit(top, name) ? name : null;
90
+ const branch = name.replace(/^origin\//, '');
91
+ const candidates = branch ? [`origin/${branch}`] : ['origin/HEAD', 'origin/master', 'origin/main'];
92
+ return candidates.find((ref) => resolveCommit(top, ref)) || null;
93
+ }
94
+
95
+ function verdict(status, reason, detail, extra = {}) {
96
+ return { status, reason, detail, ...extra };
97
+ }
98
+
99
+ // Judge each changed file by what the target holds right now.
100
+ // theirs === mine -> landed
101
+ // theirs === base (or a file the work deleted is back, or a file it added
102
+ // is gone again), after the target once carried mine -> reverted
103
+ // anything else, after the target once carried mine -> landed, edited since
104
+ // the target never carried mine -> missing
105
+ // "Once carried" is free when the commit is an ancestor of the target;
106
+ // otherwise it is a content match somewhere in the target's history of the
107
+ // file (squash, rebase and merge-queue merges rewrite commit ids).
108
+ function judgeFile(top, base, sha, targetSha, file, onTarget) {
109
+ const mine = blobAt(top, sha, file);
110
+ const before = blobAt(top, base, file);
111
+ const theirs = blobAt(top, targetSha, file);
112
+ if (theirs === mine) return 'landed';
113
+ let carried = onTarget;
114
+ if (!carried) {
115
+ const history = gitFixed(top, ['log', '--format=%H', `-${MAX_HISTORY_SCAN}`, `${base}..${targetSha}`, '--', file]);
116
+ carried = history.ok && history.out.split('\n').filter(Boolean).some((commit) => blobAt(top, commit, file) === mine);
117
+ }
118
+ if (!carried) return 'missing';
119
+ return theirs === before ? 'reverted' : 'edited_since';
120
+ }
121
+
122
+ // With no recorded start and the commit already on the target (a real merge
123
+ // or fast-forward), the fork point is the commit itself. Find the merge that
124
+ // brought it onto the target's mainline instead; the task's commits are the
125
+ // ones that merge added, and the base is the first parent of the oldest one.
126
+ // A commit sitting directly on the mainline gives no such boundary.
127
+ function baseFromMerge(top, sha, targetSha) {
128
+ const mainline = gitFixed(top, ['rev-list', '--first-parent', '--ancestry-path', `${sha}..${targetSha}`]);
129
+ const entry = mainline.ok ? mainline.out.split('\n').filter(Boolean).pop() : '';
130
+ if (!entry) return { reason: `${sha.slice(0, 9)} is the target's tip and the work recorded no start` };
131
+ const parents = gitFixed(top, ['rev-list', '--parents', '-n', '1', entry]);
132
+ const [, mainParent, ...merged] = parents.ok ? parents.out.split(/\s+/) : [];
133
+ if (!mainParent || mainParent === sha || !merged.length || isAncestor(top, sha, mainParent)) {
134
+ return { reason: `${sha.slice(0, 9)} sits directly on the target's mainline and the work recorded no start, so nothing marks where its change began` };
135
+ }
136
+ const own = gitFixed(top, ['rev-list', '--reverse', '--topo-order', `${mainParent}..${sha}`]);
137
+ const first = own.ok ? own.out.split('\n').filter(Boolean)[0] : '';
138
+ const firstParent = first ? resolveCommit(top, `${first}^1`) : null;
139
+ if (!firstParent) {
140
+ return { reason: `the merge ${entry.slice(0, 9)} that brought ${sha.slice(0, 9)} in has no parent to measure the change from` };
141
+ }
142
+ return { base: firstParent, merge: entry };
143
+ }
144
+
145
+ // Paths a verify command names, checked against what git actually tracks.
146
+ // A check that runs a file outside this checkout, or a file git does not
147
+ // have committed, proves nothing about the commit. The program itself
148
+ // (node, a venv python) is a tool, not the work, so it is not checked.
149
+ function verifyPathIssue(command, cwd, top) {
150
+ let dir = cwd;
151
+ for (const segment of String(command || '').split('&&')) {
152
+ const argv = segment.trim().split(/\s+/)
153
+ .map((token) => token.replace(/^['"`]|['"`;]$/g, ''))
154
+ .filter(Boolean);
155
+ while (argv.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) argv.shift();
156
+ if (argv[0] === 'cd') {
157
+ if (argv[1]) dir = path.resolve(dir, argv[1]);
158
+ continue;
159
+ }
160
+ for (const token of argv.slice(1)) {
161
+ if (token.startsWith('-') || token.includes('=')) continue;
162
+ if (!(path.isAbsolute(token) || token.startsWith('~') || token.includes('/') || /\.[A-Za-z0-9]+$/.test(token))) continue;
163
+ const named = token.startsWith('~')
164
+ ? path.join(process.env.HOME || '', token.slice(1))
165
+ : path.resolve(dir, token);
166
+ if (!fs.existsSync(named)) continue;
167
+ const relative = path.relative(top, fs.realpathSync(named));
168
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
169
+ return `the check runs ${token}, which lives outside this checkout`;
170
+ }
171
+ if (!relative) continue;
172
+ if (!git(top, ['ls-files', '--error-unmatch', '--', relative]).ok) {
173
+ return `the check runs ${relative}, which git does not track`;
174
+ }
175
+ if (!git(top, ['diff', '--quiet', 'HEAD', '--', relative]).ok) {
176
+ return `the check runs ${relative}, which has uncommitted changes`;
177
+ }
178
+ }
179
+ }
180
+ return '';
181
+ }
182
+
183
+ // Artifact paths a task names (receipts, run logs) are often gitignored or
184
+ // never committed. They cannot be part of a landing, so they are not held
185
+ // against one.
186
+ function trackableClaims(top, cwd, sha, changed, files) {
187
+ const claimed = [];
188
+ for (const file of files) {
189
+ const relative = path.relative(top, path.resolve(cwd, String(file))).replace(/\\/g, '/');
190
+ if (!relative || relative.startsWith('..')) continue;
191
+ if (changed.some((c) => c === relative || c.startsWith(`${relative}/`))) {
192
+ claimed.push(relative);
193
+ continue;
194
+ }
195
+ if (git(top, ['check-ignore', '-q', '--', relative]).ok) continue;
196
+ const known = gitFixed(top, ['ls-tree', '--name-only', sha, '--', relative]);
197
+ if (!known.ok || !known.out) continue;
198
+ claimed.push(relative);
199
+ }
200
+ return claimed;
201
+ }
202
+
203
+ // The check behind a change: it must be one that can fail, and when it ran
204
+ // in a working tree, that tree must hold the work, committed and tracked.
205
+ // Returns a verdict when the check does not back the change, else null.
206
+ function checkVerdict(top, cwd, { sha, baseSha, changed, claimed = [], verify, checkedWorkingTree }, facts = {}) {
207
+ const command = String(verify || '').trim();
208
+ if (!command) return verdict('unverified', 'no_check', 'no check ran on this change', facts);
209
+ const hollow = nonFalsifyingIssue(command);
210
+ if (hollow) return verdict('unverified', 'check_cannot_fail', hollow, facts);
211
+ if (!checkedWorkingTree) return null;
212
+ // The checkout holds the work when every changed file reads as the work
213
+ // wrote it, or the work is in its history and no file went back to base.
214
+ const head = resolveCommit(top, 'HEAD');
215
+ const holdsWork = head && (
216
+ changed.every((file) => blobAt(top, head, file) === blobAt(top, sha, file))
217
+ || (isAncestor(top, sha, head) && changed.every((file) => blobAt(top, head, file) !== blobAt(top, baseSha, file)))
218
+ );
219
+ if (!holdsWork) {
220
+ return verdict('unverified', 'check_ran_without_the_work', `the check ran on ${head ? head.slice(0, 9) : 'an unknown commit'}, which does not hold this work`, facts);
221
+ }
222
+ const scope = [...new Set([...changed, ...claimed])];
223
+ const dirty = git(top, ['status', '--porcelain', '--untracked-files=all', '--', ...scope]);
224
+ if (dirty.ok && dirty.out) {
225
+ const names = dirty.out.split('\n').map((line) => line.slice(3));
226
+ return verdict('unverified', 'checked_uncommitted_files', `the check ran with uncommitted edits to ${names.slice(0, 5).join(', ')}`, facts);
227
+ }
228
+ const pathIssue = verifyPathIssue(command, cwd, top);
229
+ if (pathIssue) return verdict('unverified', 'checked_untracked_content', pathIssue, facts);
230
+ return null;
231
+ }
232
+
233
+ // For a lander about to publish a commit it just built: did a check that can
234
+ // fail run on this checkout with the commit's change committed? Returns null
235
+ // when it did, else the unverified verdict saying why not. Nothing is
236
+ // published on a check that cannot back the change.
237
+ function proveCheckBeforePublish(root, { commit = 'HEAD', base, verify = '' } = {}) {
238
+ const top = toplevel(root);
239
+ if (!top) return verdict('unverified', 'not_a_git_checkout', `${root} is not a git checkout`);
240
+ const sha = resolveCommit(top, commit);
241
+ const baseSha = resolveCommit(top, base);
242
+ if (!sha || !baseSha) return verdict('unverified', 'commit_missing', 'the commit or its base does not exist here');
243
+ const diff = gitFixed(top, ['diff', '--name-only', '--no-renames', `${baseSha}..${sha}`]);
244
+ const changed = diff.ok ? diff.out.split('\n').filter(Boolean) : [];
245
+ if (!changed.length) return verdict('no_op', 'empty_diff', `${sha.slice(0, 9)} carries no change past ${baseSha.slice(0, 9)}`);
246
+ return checkVerdict(top, fs.realpathSync(root), { sha, baseSha, changed, verify, checkedWorkingTree: true }, { commit: sha, base: baseSha });
247
+ }
248
+
249
+ // root: where the check ran. commit: the work's own commit. start: the
250
+ // commit the work began from, when recorded. base: an explicit base (the
251
+ // green lander knows the target it merged onto). files: what the work claims
252
+ // to change. verify: the check command. checkedWorkingTree: the check ran in
253
+ // root's working tree, so that tree must hold the work, committed.
254
+ function proveLanding(root, { commit = 'HEAD', start = '', base = '', target = '', files = [], verify = '', checkedWorkingTree = false, localTargetOk = false } = {}) {
255
+ const top = toplevel(root);
256
+ if (!top) return verdict('unverified', 'not_a_git_checkout', `${root} is not a git checkout`);
257
+ // git reports the resolved path (/private/var on macOS); compare like with like.
258
+ const cwd = fs.realpathSync(root);
259
+ const sha = resolveCommit(top, commit);
260
+ if (!sha) return verdict('unverified', 'commit_missing', `commit ${commit} does not exist here`);
261
+ if (String(target || '').trim().startsWith('refs/heads/') && !localTargetOk) {
262
+ return verdict('unverified', 'local_target', `${target} is a local branch; only origin's copy shows the work landed where others can see it`, { commit: sha });
263
+ }
264
+ const targetRef = resolveTarget(top, target, { localTargetOk });
265
+ if (!targetRef) {
266
+ return verdict('unverified', 'no_remote_target', `no fetched origin branch${target ? ` for ${target}` : ''} to prove the landing against`, { commit: sha });
267
+ }
268
+ const targetSha = resolveCommit(top, targetRef);
269
+ const facts = { commit: sha, target: targetRef };
270
+
271
+ const startSha = start ? resolveCommit(top, start) : null;
272
+ let baseSha = base ? resolveCommit(top, base) : null;
273
+ if (!baseSha && startSha && isAncestor(top, startSha, sha)) baseSha = startSha;
274
+ if (!baseSha) {
275
+ const forked = gitFixed(top, ['merge-base', sha, targetSha]);
276
+ baseSha = forked.ok && forked.out && forked.out !== sha ? forked.out : null;
277
+ }
278
+ if (!baseSha) {
279
+ const merged = baseFromMerge(top, sha, targetSha);
280
+ if (!merged.base) {
281
+ return verdict('unverified', 'no_base', `${merged.reason}, so its own change cannot be measured`, facts);
282
+ }
283
+ baseSha = merged.base;
284
+ facts.base_from = `merge ${merged.merge}`;
285
+ }
286
+ // No rename folding: a rename is a delete plus an add, and both must land.
287
+ const diff = gitFixed(top, ['diff', '--name-only', '--no-renames', `${baseSha}..${sha}`]);
288
+ const changed = diff.ok ? diff.out.split('\n').filter(Boolean) : [];
289
+ if (changed.length === 0 || isAncestor(top, sha, baseSha)) {
290
+ return verdict('no_op', 'empty_diff', `${sha.slice(0, 9)} carries no change past ${baseSha.slice(0, 9)}`, { ...facts, base: baseSha });
291
+ }
292
+ Object.assign(facts, { base: baseSha, changed_files: changed.slice(0, 50) });
293
+
294
+ // Ancestry only says the target once carried the commit. What counts is
295
+ // what the target holds now, so every changed file is judged either way.
296
+ const onTarget = isAncestor(top, sha, targetSha);
297
+ if (!onTarget) facts.matched_by = 'content';
298
+ const judged = { landed: [], reverted: [], edited_since: [], missing: [] };
299
+ for (const file of changed) judged[judgeFile(top, baseSha, sha, targetSha, file, onTarget)].push(file);
300
+ if (judged.missing.length) {
301
+ return verdict('not_landed', 'content_not_on_target', `${targetRef} does not hold this work's version of ${judged.missing.slice(0, 5).join(', ')}`, facts);
302
+ }
303
+ if (judged.reverted.length) {
304
+ return verdict('not_landed', 'reverted', `${targetRef} carried this work, then went back to the old ${judged.reverted.slice(0, 5).join(', ')}`, { ...facts, reverted_files: judged.reverted.slice(0, 50) });
305
+ }
306
+ if (judged.edited_since.length) facts.edited_since = judged.edited_since.slice(0, 50);
307
+
308
+ const claimed = trackableClaims(top, cwd, sha, changed, files);
309
+ const untouched = claimed.filter((file) => !changed.some((c) => c === file || c.startsWith(`${file}/`)));
310
+ if (untouched.length) {
311
+ return verdict('unverified', 'claimed_files_untouched', `the change does not touch ${untouched.slice(0, 5).join(', ')}`, facts);
312
+ }
313
+
314
+ const unbacked = checkVerdict(top, cwd, { sha, baseSha, changed, claimed, verify, checkedWorkingTree }, facts);
315
+ if (unbacked) return unbacked;
316
+ if (facts.edited_since) {
317
+ return verdict('landed', 'edited_since', `landed, edited since: ${facts.edited_since.slice(0, 5).join(', ')}`, facts);
318
+ }
319
+ return verdict('landed', 'proven', '', facts);
320
+ }
321
+
322
+ // A task proves its landing from its own commits: start_commit is stamped
323
+ // at claim, work_commit when the builder marks it ready. With no commit of
324
+ // its own there is nothing to prove, whatever the checkout's HEAD says.
325
+ // verify: the check that actually ran (the verifier's cached run or the
326
+ // proof's named check). metadata.verify is the builder's claim and is only
327
+ // the fallback.
328
+ function proveTaskLanding(task, { files = [], verify = '' } = {}) {
329
+ const metadata = task.metadata || {};
330
+ const work = String(metadata.work_commit || '').trim();
331
+ const start = String(metadata.start_commit || '').trim();
332
+ if (!work) {
333
+ return verdict('unverified', 'no_task_commit', 'the task recorded no commit of its own, so nothing can be proven landed');
334
+ }
335
+ if (start && work === start) {
336
+ return verdict('no_op', 'no_commit_made', 'the task finished on the same commit it started from', { commit: work });
337
+ }
338
+ return proveLanding(task.workspace_root || process.cwd(), {
339
+ commit: work,
340
+ start,
341
+ target: metadata.target_branch || metadata.base_ref || '',
342
+ files,
343
+ verify: String(verify || '').trim() || metadata.verify,
344
+ checkedWorkingTree: true,
345
+ });
346
+ }
347
+
348
+ module.exports = {
349
+ proveCheckBeforePublish,
350
+ proveLanding,
351
+ proveTaskLanding,
352
+ };