atris 3.68.1 → 3.68.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/atris/skills/engines/SKILL.md +1 -1
- package/atris/team/customer-lead/MEMBER.md +1 -1
- package/atris/team/executor/MEMBER.md +19 -18
- package/atris/team/executor/skills/isolated-slice-builder/SKILL.md +21 -0
- package/atris/team/navigator/MEMBER.md +3 -1
- package/atris/team/navigator/skills/loophole-proof-planner/SKILL.md +20 -0
- package/atris/team/researcher/MEMBER.md +2 -1
- package/atris/team/researcher/skills/grounded-evidence-brief/SKILL.md +19 -0
- package/atris/team/validator/MEMBER.md +2 -7
- package/atris/team/validator/skills/cross-model-referee/SKILL.md +20 -0
- package/bin/atris.js +40 -1
- package/commands/autoland.js +2 -1
- package/commands/done.js +374 -0
- package/commands/engine.js +2 -2
- package/commands/goal.js +61 -2
- package/commands/land.js +34 -7
- package/commands/member.js +1542 -41
- package/commands/mission.js +506 -18
- package/commands/task.js +26 -3
- package/commands/workflow.js +1 -1
- package/commands/worktree.js +144 -34
- package/lib/auto-accept-certified.js +7 -6
- package/lib/codex-flight.js +6 -12
- package/lib/conductor-artifacts.js +3 -1
- package/lib/default-runner.js +42 -7
- package/lib/engine-ask.js +8 -1
- package/lib/engine-registry.js +17 -0
- package/lib/falsifier-probe.js +6 -0
- package/lib/first-minute.js +257 -6
- package/lib/fleet.js +163 -88
- package/lib/known-commands.js +1 -1
- package/lib/member-alive.js +1 -1
- package/lib/member-approval.js +153 -0
- package/lib/mission-task-gate.js +170 -0
- package/lib/one-lap-validator.js +2 -1
- package/lib/roster-runs.js +28 -0
- package/lib/runner-command.js +32 -8
- package/lib/task-db.js +25 -1
- package/package.json +9 -2
- package/scripts/det/ytnotes +49 -10
- package/utils/backend-root.js +4 -0
package/commands/task.js
CHANGED
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
evaluateAutoAccept,
|
|
26
26
|
isAgentCertified,
|
|
27
27
|
isAutoCertifyVerifyCommandAllowed,
|
|
28
|
+
parseVerifyChain,
|
|
28
29
|
parseVerifyCommand,
|
|
29
30
|
runVerifyCommand,
|
|
30
31
|
runVerifyCommandCached,
|
|
@@ -2204,6 +2205,7 @@ function taskReviewCleanEvidenceCommand(command) {
|
|
|
2204
2205
|
/\s+\(?(?:passed|ok|clean|failed|errored|timed out|succeeded|succeeds|successful|confirmed)\)?$/i,
|
|
2205
2206
|
/\s+\(?\d+\/\d+(?:\s+(?:tests?|checks?|passed|pass|ok|clean|failed|failures?))?\)?$/i,
|
|
2206
2207
|
/\s+\d+\/\d+(?:\s+(?:tests?|checks?|passed|pass|ok|clean|failed|failures?))?$/i,
|
|
2208
|
+
/\s+(?:passed|exited\s+0)(?:\s*\(\s*exit\s+0\s*\))?(?:\.(?:\s.*)?)?$/i,
|
|
2207
2209
|
];
|
|
2208
2210
|
for (const pattern of tailPatterns) {
|
|
2209
2211
|
clean = taskReviewTrimOutsideQuotedTail(clean, pattern);
|
|
@@ -2254,7 +2256,7 @@ function taskReviewCommandIsDescribedSubject(clause, commandIndex) {
|
|
|
2254
2256
|
function taskReviewEvidenceCommands(text, limit = 8) {
|
|
2255
2257
|
const source = String(text || '').trim();
|
|
2256
2258
|
if (!source) return [];
|
|
2257
|
-
const commandWord = '(?:npm|node|git|atris|npx|pnpm|yarn|python3?|pytest|bash|sh|tsc|vitest|curl|gh|rg)';
|
|
2259
|
+
const commandWord = '(?:npm|node|git|atris|npx|pnpm|yarn|python3?|pytest|bash|sh|tsc|vitest|curl|gh|rg|test(?=\\s+-[sf]\\b))';
|
|
2258
2260
|
const envPrefix = '(?:(?:[A-Z_][A-Z0-9_]*=[^\\s,;|]+)\\s+)*';
|
|
2259
2261
|
const prosePrefix = '(?:(?:rechecked|reran|re-run|run|verified|validated|passed|validation(?:\\s+passed)?|verification(?:\\s+passed)?|focused|live|scoped|installed|direct|full|current|fresh|then|and|commands?|checks?)[:\\s]+)*';
|
|
2260
2262
|
const commandStart = `${prosePrefix}${envPrefix}${commandWord}\\b`;
|
|
@@ -10460,16 +10462,35 @@ function certifyVerifyCandidate(task) {
|
|
|
10460
10462
|
const review = task.review || {};
|
|
10461
10463
|
const recorded = typeof metadata.verify === 'string' && metadata.verify.trim() ? [metadata.verify.trim()] : [];
|
|
10462
10464
|
const proof = String(review.proof || metadata.latest_agent_proof || '');
|
|
10463
|
-
|
|
10465
|
+
// The extractor splits on &&, so rebuild chains the proof actually wrote
|
|
10466
|
+
// ("a && b") and try each one before its pieces.
|
|
10467
|
+
const flatProof = proof.replace(/`/g, '').replace(/\s+/g, ' ');
|
|
10468
|
+
const pieces = taskReviewEvidenceCommands(proof);
|
|
10469
|
+
const ordered = [];
|
|
10470
|
+
for (let i = 0; i < pieces.length; i += 1) {
|
|
10471
|
+
for (let j = pieces.length; j > i + 1; j -= 1) {
|
|
10472
|
+
const chain = pieces.slice(i, j).join(' && ');
|
|
10473
|
+
if (flatProof.includes(chain)) ordered.push(chain);
|
|
10474
|
+
}
|
|
10475
|
+
ordered.push(pieces[i]);
|
|
10476
|
+
}
|
|
10477
|
+
for (const raw of [...recorded, ...ordered]) {
|
|
10464
10478
|
// The evidence extractor can keep trailing prose ("git diff --check. Evidence
|
|
10465
10479
|
// inspected: ..."); try the full clause, then the first sentence of it.
|
|
10466
10480
|
for (const candidate of [raw, raw.split(/\.(?:\s|$)/)[0]].map((c) => String(c || '').trim())) {
|
|
10467
|
-
if (candidate &&
|
|
10481
|
+
if (candidate && isAutoCertifyVerifyCommandAllowed(candidate) && !verifyIsOnlyFileChecks(candidate)) return candidate;
|
|
10468
10482
|
}
|
|
10469
10483
|
}
|
|
10470
10484
|
return null;
|
|
10471
10485
|
}
|
|
10472
10486
|
|
|
10487
|
+
// "File exists" is not certification: a verify made only of test -s / test -f
|
|
10488
|
+
// steps does not qualify on its own.
|
|
10489
|
+
function verifyIsOnlyFileChecks(verify) {
|
|
10490
|
+
const chain = parseVerifyChain(verify);
|
|
10491
|
+
return chain.ok && chain.steps.every((step) => step.argv[0] === 'test' && /^-[sf]$/.test(step.argv[1] || ''));
|
|
10492
|
+
}
|
|
10493
|
+
|
|
10473
10494
|
function stampCertifyVerifyMetadata(taskDb, db, taskId, actor, verify) {
|
|
10474
10495
|
const row = taskDb.getTask(db, taskId);
|
|
10475
10496
|
if (!row) return;
|
|
@@ -13573,4 +13594,6 @@ module.exports = {
|
|
|
13573
13594
|
taskApprovalFor,
|
|
13574
13595
|
taskPageContract,
|
|
13575
13596
|
taskDescriptionForCloud,
|
|
13597
|
+
taskReviewCleanEvidenceCommand,
|
|
13598
|
+
certifyVerifyCandidate,
|
|
13576
13599
|
};
|
package/commands/workflow.js
CHANGED
|
@@ -83,7 +83,7 @@ function reviewSoftTitle(title, maxWords = 5) {
|
|
|
83
83
|
|
|
84
84
|
function isHumanDeskNext(command) {
|
|
85
85
|
const text = String(command || '');
|
|
86
|
-
return /^atris do\b/.test(text) || /^atris task (?:claim|show|ready|accept)\b/.test(text);
|
|
86
|
+
return /^atris do\b/.test(text) || /^atris done\b/.test(text) || /^atris task (?:claim|show|ready|accept)\b/.test(text);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function loadReviewTasks(root = process.cwd()) {
|
package/commands/worktree.js
CHANGED
|
@@ -7,6 +7,7 @@ const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
|
7
7
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
8
8
|
const { isConductorArtifact } = require('../lib/conductor-artifacts');
|
|
9
9
|
const { fixMapDocs } = require('../lib/map-refs');
|
|
10
|
+
const { sleepSync } = require('../lib/sleep-sync');
|
|
10
11
|
const close = require('./close');
|
|
11
12
|
|
|
12
13
|
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
@@ -16,15 +17,48 @@ const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
|
16
17
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
18
|
const SHIP_HEALTH_SOURCE = 'ship-health:experiments-daily';
|
|
18
19
|
|
|
20
|
+
function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
|
|
21
|
+
const env = { ...source, GIT_TERMINAL_PROMPT: '0' };
|
|
22
|
+
// Custom commands may be wrappers that do not accept OpenSSH flags.
|
|
23
|
+
// Leave Git's own command/config/program precedence intact.
|
|
24
|
+
if (source.GIT_SSH_COMMAND || source.GIT_SSH) return env;
|
|
25
|
+
const configured = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
|
|
26
|
+
cwd, encoding: 'utf8', env: source, timeout: 5000, killSignal: 'SIGKILL',
|
|
27
|
+
});
|
|
28
|
+
if (configured.status === 0 && String(configured.stdout || '').trim()) return env;
|
|
29
|
+
env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15';
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A killed network call leaves stderr empty; name the timeout instead.
|
|
34
|
+
function gitFailureDetail(result) {
|
|
35
|
+
if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
|
|
36
|
+
return String((result && (result.stderr || result.stdout)) || '').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
20
|
-
const
|
|
40
|
+
const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
|
|
41
|
+
// Only network calls need the SSH probe; local calls skip its extra git spawn.
|
|
42
|
+
const options = { cwd, encoding: 'utf8', timeout: timeout ?? (network ? 30000 : undefined),
|
|
43
|
+
killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
|
|
44
|
+
let result = spawnSync('git', args, options);
|
|
45
|
+
// Linked checkouts share config. A concurrent start must not silently lose
|
|
46
|
+
// its owner/base metadata, but real config errors still stop immediately.
|
|
47
|
+
for (let retry = 1; args[0] === 'config' && retry < 3 && configLocked(result); retry += 1) {
|
|
48
|
+
sleepSync(100 * retry);
|
|
49
|
+
result = spawnSync('git', args, options);
|
|
50
|
+
}
|
|
21
51
|
if (check && result.status !== 0) {
|
|
22
|
-
const
|
|
23
|
-
throw new Error(
|
|
52
|
+
const detail = gitFailureDetail(result);
|
|
53
|
+
throw new Error(detail === 'timed out' ? `git ${args[0]} timed out` : (detail || `git ${args.join(' ')} failed`));
|
|
24
54
|
}
|
|
25
55
|
return result;
|
|
26
56
|
}
|
|
27
57
|
|
|
58
|
+
function configLocked(result) {
|
|
59
|
+
return result.status !== 0 && /could not lock config file/i.test(`${result.stderr || ''}${result.stdout || ''}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
|
|
29
63
|
const result = spawnSync(command, {
|
|
30
64
|
cwd,
|
|
@@ -285,6 +319,23 @@ function currentUpstream(root) {
|
|
|
285
319
|
}
|
|
286
320
|
|
|
287
321
|
function remoteHead(root) {
|
|
322
|
+
// The saved origin/HEAD survives branch renames. Ask the remote before
|
|
323
|
+
// trusting it, keeping the offline fallback when the remote is unavailable.
|
|
324
|
+
const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
|
|
325
|
+
if (remote.status === 0) {
|
|
326
|
+
const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
|
|
327
|
+
const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
|
|
328
|
+
if (match) {
|
|
329
|
+
const head = `origin/${match[1]}`;
|
|
330
|
+
if (!refExists(root, head)) {
|
|
331
|
+
const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
|
|
332
|
+
if (fetched.status !== 0 || !refExists(root, head)) {
|
|
333
|
+
throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return head;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
288
339
|
const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
|
|
289
340
|
return result.status === 0 ? result.stdout.trim() : '';
|
|
290
341
|
}
|
|
@@ -311,7 +362,7 @@ function normalizeTargetRef(root, target) {
|
|
|
311
362
|
function defaultMainlineBase(root) {
|
|
312
363
|
const head = remoteHead(root);
|
|
313
364
|
if (head && refExists(root, head)) return head;
|
|
314
|
-
for (const candidate of ['origin/
|
|
365
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
315
366
|
if (refExists(root, candidate)) return candidate;
|
|
316
367
|
}
|
|
317
368
|
return 'HEAD';
|
|
@@ -342,7 +393,7 @@ function defaultShipTarget(root) {
|
|
|
342
393
|
}
|
|
343
394
|
const head = remoteHead(root);
|
|
344
395
|
if (head && refExists(root, head)) return head;
|
|
345
|
-
for (const candidate of ['origin/
|
|
396
|
+
for (const candidate of ['origin/main', 'origin/master']) {
|
|
346
397
|
if (refExists(root, candidate)) return candidate;
|
|
347
398
|
}
|
|
348
399
|
return 'HEAD';
|
|
@@ -351,7 +402,8 @@ function defaultShipTarget(root) {
|
|
|
351
402
|
function refreshRemoteRef(root, ref) {
|
|
352
403
|
if (!ref.startsWith('origin/')) return;
|
|
353
404
|
const remoteBranch = ref.slice('origin/'.length);
|
|
354
|
-
runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false });
|
|
405
|
+
const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
|
|
406
|
+
return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
|
|
355
407
|
}
|
|
356
408
|
|
|
357
409
|
function baseBranchName(ref) {
|
|
@@ -456,7 +508,7 @@ function printStatus() {
|
|
|
456
508
|
// worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
|
|
457
509
|
// separate on purpose. checkoutBase defaults to the launcher's own
|
|
458
510
|
// upstream/default remote base, so the agent starts from current work.
|
|
459
|
-
// shipBase defaults to
|
|
511
|
+
// shipBase defaults to the remote mainline for agent/member worktrees regardless of
|
|
460
512
|
// what branch the launcher happened to have checked out. recording the
|
|
461
513
|
// launcher's own feature branch as the ship target false-landed two PRs on
|
|
462
514
|
// 2026-07-04 (ship merged into the launcher's branch, never reached master).
|
|
@@ -465,48 +517,85 @@ function printStatus() {
|
|
|
465
517
|
function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
|
|
466
518
|
const owner = member || agent;
|
|
467
519
|
if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
|
|
520
|
+
// Pin the caller's flight base, or resolve it once for this new flight.
|
|
521
|
+
const mainline = baseOverride || defaultMainlineBase(root);
|
|
468
522
|
// Every new flight pays down completed checkout debt before adding another
|
|
469
523
|
// full repo copy. Branches and commits survive worktree removal, while real
|
|
470
524
|
// uncommitted changes remain protected by cleanupWorktrees.
|
|
471
525
|
let reapedBeforeStart = [];
|
|
472
526
|
let cachePrunedBeforeStart = [];
|
|
473
527
|
try {
|
|
474
|
-
const cleaned = cleanupWorktrees({ root, base:
|
|
528
|
+
const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start' });
|
|
475
529
|
reapedBeforeStart = cleaned.removed;
|
|
476
530
|
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
477
531
|
} catch {}
|
|
478
532
|
const branch = branchOverride || branchName(owner, task, now);
|
|
479
533
|
const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
480
|
-
const explicitBase = Boolean(baseOverride);
|
|
481
534
|
// Agent/member worktrees always cut from the mainline: honoring the
|
|
482
535
|
// launcher checkout's upstream let a dispatch inherit another session's
|
|
483
536
|
// in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
|
|
484
537
|
// unpushed commits from a parallel session and could not merge).
|
|
485
|
-
const checkoutBase = normalizeTargetRef(root,
|
|
486
|
-
const shipBase =
|
|
538
|
+
const checkoutBase = normalizeTargetRef(root, mainline);
|
|
539
|
+
const shipBase = checkoutBase;
|
|
487
540
|
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
488
541
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
489
|
-
refreshRemoteRef(root, checkoutBase);
|
|
490
|
-
|
|
491
|
-
runGit(['
|
|
492
|
-
runGit(['
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
542
|
+
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
543
|
+
const ref = `refs/heads/${branch}`;
|
|
544
|
+
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
545
|
+
const baseCommit = runGit(['rev-parse', '--verify', `${checkoutBase}^{commit}`], { cwd: root, check: false });
|
|
546
|
+
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
547
|
+
// With no local copy the start fails on its own; only a real cut gets the warning.
|
|
548
|
+
if (staleBase && startCommit) {
|
|
549
|
+
console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
|
|
550
|
+
}
|
|
551
|
+
const discardFailedStart = () => {
|
|
552
|
+
if (existed || !startCommit) return;
|
|
553
|
+
const registered = listWorktrees(root).find((wt) => wt.branch === branch);
|
|
554
|
+
if (registered) {
|
|
555
|
+
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
556
|
+
|| registered.head !== startCommit) return;
|
|
557
|
+
const dirt = statusCounts(target, { ignoreUntracked: isConductorArtifact });
|
|
558
|
+
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
559
|
+
// No engine has started. Normal removal still refuses unexpected dirt.
|
|
560
|
+
runGit(['worktree', 'remove', target], { cwd: root });
|
|
561
|
+
}
|
|
562
|
+
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
563
|
+
};
|
|
564
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
565
|
+
const added = runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root, check: false });
|
|
566
|
+
if (added.status === 0) break;
|
|
567
|
+
// Git can create the branch before failing to write its tracking config.
|
|
568
|
+
// Drop only that untouched ref, never an existing branch or a checkout.
|
|
569
|
+
if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
|
|
570
|
+
if (!configLocked(added) || attempt === 2) {
|
|
571
|
+
throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
|
|
572
|
+
}
|
|
573
|
+
sleepSync(100 * (attempt + 1));
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
577
|
+
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
578
|
+
runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
|
|
579
|
+
fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
|
|
580
|
+
fs.writeFileSync(
|
|
581
|
+
path.join(target, '.atris', 'agent-worktree.json'),
|
|
582
|
+
JSON.stringify({
|
|
583
|
+
agent: agent || null,
|
|
584
|
+
member: member || null,
|
|
585
|
+
owner,
|
|
586
|
+
task,
|
|
587
|
+
branch,
|
|
588
|
+
base: shipBase,
|
|
589
|
+
checkout_base: checkoutBase,
|
|
590
|
+
workspace_root: root,
|
|
591
|
+
created_at: now.toISOString(),
|
|
592
|
+
}, null, 2) + '\n',
|
|
593
|
+
'utf8'
|
|
594
|
+
);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
discardFailedStart();
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
510
599
|
return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
511
600
|
}
|
|
512
601
|
|
|
@@ -903,7 +992,7 @@ function shipWorktree(args) {
|
|
|
903
992
|
throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
|
|
904
993
|
}
|
|
905
994
|
}
|
|
906
|
-
const primaryCounts = statusCounts(primary) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
995
|
+
const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorArtifact }) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
907
996
|
if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
|
|
908
997
|
throw new Error(
|
|
909
998
|
`primary checkout dirty staged=${primaryCounts.staged} ` +
|
|
@@ -1058,6 +1147,20 @@ function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
|
1058
1147
|
});
|
|
1059
1148
|
}
|
|
1060
1149
|
|
|
1150
|
+
// Every work-folder removal leaves one line naming who removed it and why, in
|
|
1151
|
+
// the main checkout's state so the line outlives the folder it describes.
|
|
1152
|
+
function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
|
|
1153
|
+
try {
|
|
1154
|
+
const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
|
|
1155
|
+
const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
|
|
1156
|
+
const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
|
|
1157
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1158
|
+
fs.appendFileSync(file, `${JSON.stringify({
|
|
1159
|
+
at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
|
|
1160
|
+
})}\n`);
|
|
1161
|
+
} catch {}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1061
1164
|
function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
|
|
1062
1165
|
const candidates = [];
|
|
1063
1166
|
const pruned = [];
|
|
@@ -1079,7 +1182,7 @@ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
|
|
|
1079
1182
|
return { candidates, pruned };
|
|
1080
1183
|
}
|
|
1081
1184
|
|
|
1082
|
-
function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride } = {}) {
|
|
1185
|
+
function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
|
|
1083
1186
|
const worktrees = listWorktrees(root);
|
|
1084
1187
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1085
1188
|
const current = path.resolve(root);
|
|
@@ -1167,6 +1270,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1167
1270
|
const removedResult = runGit(removeArgs, { cwd: root, check: false });
|
|
1168
1271
|
if (removedResult.status === 0) {
|
|
1169
1272
|
removed.push(candidate);
|
|
1273
|
+
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1170
1274
|
} else {
|
|
1171
1275
|
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1172
1276
|
}
|
|
@@ -1185,6 +1289,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1185
1289
|
const removedResult = runGit(removeArgs, { cwd: root, check: false });
|
|
1186
1290
|
if (removedResult.status === 0) {
|
|
1187
1291
|
removed.push(candidate);
|
|
1292
|
+
recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
|
|
1188
1293
|
} else {
|
|
1189
1294
|
kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
|
|
1190
1295
|
}
|
|
@@ -1302,12 +1407,16 @@ module.exports = {
|
|
|
1302
1407
|
cleanupWorktrees,
|
|
1303
1408
|
defaultMainlineBase,
|
|
1304
1409
|
defaultStartBase,
|
|
1410
|
+
gitFailureDetail,
|
|
1411
|
+
gitNetworkEnvironment,
|
|
1305
1412
|
describeFlightAge,
|
|
1306
1413
|
flightStampMs,
|
|
1414
|
+
listActiveProcessCwds,
|
|
1307
1415
|
listWorktrees,
|
|
1308
1416
|
parseAgentFlightName,
|
|
1309
1417
|
parseWorktrees,
|
|
1310
1418
|
normalizeTargetRef,
|
|
1419
|
+
recordWorktreeRemoval,
|
|
1311
1420
|
refreshMovedMapRefs,
|
|
1312
1421
|
slugify,
|
|
1313
1422
|
taskTokens,
|
|
@@ -1315,4 +1424,5 @@ module.exports = {
|
|
|
1315
1424
|
swarloClaim,
|
|
1316
1425
|
runShipHealthCheck,
|
|
1317
1426
|
worktreeCommand,
|
|
1427
|
+
worktreeHasActiveProcess,
|
|
1318
1428
|
};
|
|
@@ -578,7 +578,8 @@ function runVerifyStep(parsed, workspaceRoot) {
|
|
|
578
578
|
if (!gitPathCheck.ok) return gitPathCheck;
|
|
579
579
|
const pythonPathCheck = validatePythonBinaryPath(parsed.argv, workspaceRoot, cwdCheck.cwd);
|
|
580
580
|
if (!pythonPathCheck.ok) return pythonPathCheck;
|
|
581
|
-
const
|
|
581
|
+
const bin = parsed.argv[0] === 'node' ? process.execPath : parsed.argv[0];
|
|
582
|
+
const result = spawnSync(bin, parsed.argv.slice(1), {
|
|
582
583
|
cwd: cwdCheck.cwd,
|
|
583
584
|
env: verifyCommandEnv(parsed.env),
|
|
584
585
|
shell: false,
|
|
@@ -620,11 +621,10 @@ function runVerifyStep(parsed, workspaceRoot) {
|
|
|
620
621
|
function verifyCommandEnv(extraEnv) {
|
|
621
622
|
const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
|
|
622
623
|
const binDir = path.dirname(process.execPath);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
624
|
+
// Caller PATH order wins (a test may put a fake binary first); the node bin
|
|
625
|
+
// dir and Homebrew only fill in at the end when missing.
|
|
626
|
+
const currentParts = String(base.PATH || '').split(path.delimiter).filter(Boolean);
|
|
627
|
+
base.PATH = [...new Set([...currentParts, binDir, '/opt/homebrew/bin'].filter(Boolean))].join(path.delimiter);
|
|
628
628
|
base[VERIFY_IN_PROGRESS_ENV] = '1';
|
|
629
629
|
// A verifier that inherits a test-runner context joins the outer runner as
|
|
630
630
|
// a child and reports instant exit 0 without running anything. Strip it so
|
|
@@ -1409,4 +1409,5 @@ module.exports = {
|
|
|
1409
1409
|
repoHygieneGate,
|
|
1410
1410
|
runVerifyCommand,
|
|
1411
1411
|
runVerifyCommandCached,
|
|
1412
|
+
verifyCommandEnv,
|
|
1412
1413
|
};
|
package/lib/codex-flight.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawn, spawnSync } = require('child_process');
|
|
7
|
+
const { defaultMainlineBase } = require('../commands/worktree');
|
|
7
8
|
const { SHARED_BRIEF_RULES } = require('./brief-rules');
|
|
8
9
|
|
|
9
10
|
// Worktree flights need full file access to edit and ship the requested repo.
|
|
@@ -78,15 +79,10 @@ function renderCommand(argv) {
|
|
|
78
79
|
function runWorktreeAdd(repoPath, branch, worktreePath) {
|
|
79
80
|
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
80
81
|
const baseArgs = ['-C', repoPath, 'worktree', 'add', '-b', branch, worktreePath];
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const detail = [
|
|
86
|
-
`origin/master: ${(master.stderr || master.stdout || '').trim()}`,
|
|
87
|
-
`origin/main: ${(main.stderr || main.stdout || '').trim()}`,
|
|
88
|
-
].join('\n');
|
|
89
|
-
throw new Error(`git worktree add failed for ${worktreePath}\n${detail}`);
|
|
82
|
+
const baseRef = defaultMainlineBase(repoPath);
|
|
83
|
+
const result = spawnSync('git', [...baseArgs, baseRef], { encoding: 'utf8' });
|
|
84
|
+
if (result.status === 0) return { baseRef };
|
|
85
|
+
throw new Error(`git worktree add failed for ${worktreePath}\n${baseRef}: ${(result.stderr || result.stdout || '').trim()}`);
|
|
90
86
|
}
|
|
91
87
|
|
|
92
88
|
function parseTaskId(stdout) {
|
|
@@ -152,12 +148,10 @@ async function dispatchCodexFlight({ repoPath, slug, brief, verifyCmd, dryRun =
|
|
|
152
148
|
const branch = `codex/${flightSlug}-${flightStamp}`;
|
|
153
149
|
const worktreePath = path.join(repo.arenaRoot, '.agent-worktrees', repo.repoName, `codex-${flightSlug}-${flightStamp}`);
|
|
154
150
|
const prompt = buildPrompt({ worktreePath, branch, brief, verifyCmd });
|
|
155
|
-
const worktreeCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath,
|
|
156
|
-
const fallbackCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, 'origin/main'];
|
|
151
|
+
const worktreeCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, defaultMainlineBase(repo.repoPath)];
|
|
157
152
|
|
|
158
153
|
if (dryRun) {
|
|
159
154
|
console.log(`Worktree command: ${renderCommand(worktreeCommand)}`);
|
|
160
|
-
console.log(`Fallback command: ${renderCommand(fallbackCommand)}`);
|
|
161
155
|
console.log(`Codex cwd: ${worktreePath}`);
|
|
162
156
|
console.log('Prompt:');
|
|
163
157
|
console.log(prompt);
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
const CONDUCTOR_UNTRACKED_PATTERN =
|
|
9
9
|
/^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog\.js|codex-watchdog-[^/]+\.json|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
|
|
10
10
|
const CONDUCTOR_ROOT_OUTPUT_PATTERN = /^\.codex-last-message\.txt$/;
|
|
11
|
+
const CONDUCTOR_RUN_OUTPUT_PATTERN = /^atris\/runs\/[^/]+\.(?:json|live\.log)$/;
|
|
11
12
|
|
|
12
13
|
function isConductorArtifact(file) {
|
|
13
14
|
const value = String(file || '');
|
|
14
|
-
return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value)
|
|
15
|
+
return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value)
|
|
16
|
+
|| CONDUCTOR_RUN_OUTPUT_PATTERN.test(value);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
// Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
|
package/lib/default-runner.js
CHANGED
|
@@ -19,27 +19,62 @@ function hasFlag(args, name) {
|
|
|
19
19
|
return list.some((token) => String(token) === name || String(token).startsWith(`${name}=`));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Codex exports CODEX_THREAD_ID (and CODEX_SESSION_ID on some builds) into
|
|
23
|
+
// every shell command its agent runs, so a CLI started by a codex session can
|
|
24
|
+
// see it. ATRIS_CALLER_RUNTIME names the caller outright and wins over the
|
|
25
|
+
// sniffing: codex, agent (claude code, cursor, devin), or terminal.
|
|
26
|
+
const CODEX_SESSION_ENV = Object.freeze(['CODEX_THREAD_ID', 'CODEX_SESSION_ID']);
|
|
27
|
+
const AGENT_SESSION_ENV = Object.freeze(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CURSOR_AGENT', 'DEVIN_SESSION_ID']);
|
|
28
|
+
|
|
29
|
+
function envSet(env, name) {
|
|
30
|
+
return String((env && env[name]) || '').trim() !== '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function declaredCallerRuntime(env = process.env) {
|
|
34
|
+
const named = String((env && env.ATRIS_CALLER_RUNTIME) || '').trim().toLowerCase();
|
|
35
|
+
return ['codex', 'agent', 'terminal'].includes(named) ? named : '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function codexSessionFromEnv(env = process.env) {
|
|
39
|
+
const named = declaredCallerRuntime(env);
|
|
40
|
+
if (named) return named === 'codex';
|
|
41
|
+
return CODEX_SESSION_ENV.some((name) => envSet(env, name));
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
// A live codex session drives a run by passing its native goal slot state
|
|
23
|
-
// (--native-goal-* / --visible-goal-*)
|
|
24
|
-
// (--take-goal-slot)
|
|
25
|
-
|
|
45
|
+
// (--native-goal-* / --visible-goal-*), by explicitly claiming the slot
|
|
46
|
+
// (--take-goal-slot), or by being the process around this CLI (its session
|
|
47
|
+
// env). Absent those, nothing is on the other end.
|
|
48
|
+
function runHasLiveCodexSession(args, env = process.env) {
|
|
26
49
|
return Boolean(
|
|
27
50
|
flagValue(args, '--native-goal-status')
|
|
28
51
|
|| flagValue(args, '--visible-goal-status')
|
|
29
52
|
|| flagValue(args, '--native-goal-objective')
|
|
30
53
|
|| flagValue(args, '--visible-goal-objective')
|
|
31
|
-
|| hasFlag(args, '--take-goal-slot')
|
|
54
|
+
|| hasFlag(args, '--take-goal-slot')
|
|
55
|
+
|| codexSessionFromEnv(env),
|
|
32
56
|
);
|
|
33
57
|
}
|
|
34
58
|
|
|
59
|
+
// Who started this CLI: 'codex' (a codex session that can start a native
|
|
60
|
+
// goal), 'agent' (another agent session that does the work itself), or
|
|
61
|
+
// 'terminal' (a plain shell, cron, or launchd, where nothing else will do the
|
|
62
|
+
// work unless Atris starts an engine).
|
|
63
|
+
function callerRuntime(args = [], env = process.env) {
|
|
64
|
+
if (runHasLiveCodexSession(args, env)) return 'codex';
|
|
65
|
+
const named = declaredCallerRuntime(env);
|
|
66
|
+
if (named) return named;
|
|
67
|
+
return AGENT_SESSION_ENV.some((name) => envSet(env, name)) ? 'agent' : 'terminal';
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
// Default runner for the run-objective path (`atris mission run "<objective>"`,
|
|
36
71
|
// `member run`). codex_goal hands the mission to a live codex session's native
|
|
37
72
|
// goal slot; with no such session (cron, fleet, a plain shell) it stalls forever
|
|
38
73
|
// waiting for a native goal start that never comes (proven footgun 2026-07-16).
|
|
39
74
|
// So codex_goal only when a live codex session is driving; otherwise claude,
|
|
40
75
|
// which drives itself headless. An explicit --runner always wins over this.
|
|
41
|
-
function defaultObjectiveRunner(args) {
|
|
42
|
-
return runHasLiveCodexSession(args) ? 'codex_goal' : 'claude';
|
|
76
|
+
function defaultObjectiveRunner(args, env = process.env) {
|
|
77
|
+
return runHasLiveCodexSession(args, env) ? 'codex_goal' : 'claude';
|
|
43
78
|
}
|
|
44
79
|
|
|
45
|
-
module.exports = { runHasLiveCodexSession, defaultObjectiveRunner };
|
|
80
|
+
module.exports = { callerRuntime, runHasLiveCodexSession, defaultObjectiveRunner };
|
package/lib/engine-ask.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
} = require('./runner-command');
|
|
12
12
|
const {
|
|
13
13
|
canonicalEngineName,
|
|
14
|
+
claudeModelId,
|
|
14
15
|
currentClaudeModel,
|
|
15
16
|
engineFailureHealthStatus,
|
|
16
17
|
setEngineHealth,
|
|
@@ -242,7 +243,13 @@ function assertAskModelSupported(engine, model) {
|
|
|
242
243
|
|
|
243
244
|
function resolveAskModel(engine, modelName = '') {
|
|
244
245
|
const requested = String(modelName || '').trim();
|
|
245
|
-
if (requested)
|
|
246
|
+
if (requested) {
|
|
247
|
+
// A display name ("opus 5.5") becomes an id only for the claude cli; every
|
|
248
|
+
// other engine gets its model name as typed.
|
|
249
|
+
return engine === 'claude' || engine === 'fable' || engine === 'haiku'
|
|
250
|
+
? claudeModelId(requested)
|
|
251
|
+
: currentClaudeModel(requested);
|
|
252
|
+
}
|
|
246
253
|
if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
|
|
247
254
|
return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
|
|
248
255
|
}
|
package/lib/engine-registry.js
CHANGED
|
@@ -592,6 +592,7 @@ const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable',
|
|
|
592
592
|
// A retired model runs as its replacement, so a saved pick keeps working
|
|
593
593
|
// after the upgrade. Retiring the next one is one line here.
|
|
594
594
|
const RETIRED_CLAUDE_MODELS = Object.freeze({
|
|
595
|
+
'claude-fable-5': 'claude-fable-5-1',
|
|
595
596
|
'claude-haiku-4-5': 'claude-haiku-5-5',
|
|
596
597
|
'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
|
|
597
598
|
});
|
|
@@ -619,6 +620,21 @@ function currentClaudeModel(value) {
|
|
|
619
620
|
return next ? `${next}${suffix}` : raw;
|
|
620
621
|
}
|
|
621
622
|
|
|
623
|
+
// The id the claude cli accepts for a display name typed with a space:
|
|
624
|
+
// "opus 5.5" is claude-opus-5-5, "fable 5.1" is claude-fable-5-1. A full id,
|
|
625
|
+
// a bare alias ("opus"), a "[1m]" tail and anything else pass through as given
|
|
626
|
+
// (a retired id still becomes its replacement).
|
|
627
|
+
const CLAUDE_DISPLAY_NAME = /^(opus|sonnet|haiku|fable)\s+(\d+)(?:\.(\d+))?$/i;
|
|
628
|
+
|
|
629
|
+
function claudeModelId(value) {
|
|
630
|
+
const raw = String(value || '').trim();
|
|
631
|
+
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
632
|
+
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
633
|
+
const match = CLAUDE_DISPLAY_NAME.exec(base);
|
|
634
|
+
if (!match) return currentClaudeModel(raw);
|
|
635
|
+
return currentClaudeModel(`claude-${match[1].toLowerCase()}-${match[2]}${match[3] ? `-${match[3]}` : ''}${suffix}`);
|
|
636
|
+
}
|
|
637
|
+
|
|
622
638
|
// The retired models a roster file's text still names, by id or spelled
|
|
623
639
|
// out, each once, so the roster can say they were upgraded.
|
|
624
640
|
function retiredModelsNamed(text) {
|
|
@@ -2386,6 +2402,7 @@ module.exports = {
|
|
|
2386
2402
|
confirmRoster,
|
|
2387
2403
|
normalizeRosterModel,
|
|
2388
2404
|
currentClaudeModel,
|
|
2405
|
+
claudeModelId,
|
|
2389
2406
|
retiredModelsNamed,
|
|
2390
2407
|
RETIRED_CLAUDE_MODELS,
|
|
2391
2408
|
isClaudeFamilyModel,
|
package/lib/falsifier-probe.js
CHANGED
|
@@ -47,10 +47,16 @@ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
|
|
|
47
47
|
const dir = fs.mkdtempSync(path.join(tmpRoot || os.tmpdir(), 'atris-falsifier-probe-'));
|
|
48
48
|
try {
|
|
49
49
|
const run = runner || spawnSync;
|
|
50
|
+
// A probe that inherits a test-runner context joins the outer runner as a
|
|
51
|
+
// child and exits 0 without running anything, so every check would look
|
|
52
|
+
// like it cannot fail. Same strip as verifyCommandEnv.
|
|
53
|
+
const env = { ...process.env };
|
|
54
|
+
delete env.NODE_TEST_CONTEXT;
|
|
50
55
|
const result = run('bash', ['-lc', cmd], {
|
|
51
56
|
cwd: dir,
|
|
52
57
|
encoding: 'utf8',
|
|
53
58
|
timeout: PROBE_TIMEOUT_MS,
|
|
59
|
+
env,
|
|
54
60
|
});
|
|
55
61
|
if (result.error) {
|
|
56
62
|
const timedOut = /ETIMEDOUT/i.test(String(result.error.code || result.error.message || ''));
|