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.
- package/README.md +40 -1
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +26 -6
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +759 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +433 -99
- package/commands/mission.js +332 -41
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +132 -16
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +148 -18
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +242 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +35 -4
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/engine-validate.js +189 -9
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +479 -10
- package/lib/member-board.js +86 -10
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +433 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +63 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/lib/known-commands.js
CHANGED
|
@@ -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
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
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
|
+
};
|