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/commands/worktree.js
CHANGED
|
@@ -5,12 +5,16 @@ const path = require('path');
|
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
7
7
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
8
|
-
const { isConductorArtifact } = require('../lib/conductor-artifacts');
|
|
8
|
+
const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
|
|
9
9
|
const { fixMapDocs } = require('../lib/map-refs');
|
|
10
10
|
const { sleepSync } = require('../lib/sleep-sync');
|
|
11
11
|
const close = require('./close');
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Files atris regenerates on its own. Unstaged churn in them is restored
|
|
14
|
+
// before ship commits, unless the commit message names the file. The task
|
|
15
|
+
// projection is rewritten by any task command run in a work folder, and two
|
|
16
|
+
// parallel landings that each carry a copy always clash.
|
|
17
|
+
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
|
|
14
18
|
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
15
19
|
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
16
20
|
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
@@ -159,6 +163,34 @@ function branchName(member, task, now = new Date()) {
|
|
|
159
163
|
return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
// The newest earlier run of this same member+job, if its commits never reached
|
|
167
|
+
// the base, so a retry continues that work instead of redoing it. Only the
|
|
168
|
+
// newest run counts: once it landed or did nothing, anything older is stale.
|
|
169
|
+
function lastRunBranch(root, owner, task, base, skip) {
|
|
170
|
+
const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
|
|
171
|
+
const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
|
|
172
|
+
if (listed.status !== 0) return null;
|
|
173
|
+
// Only exact runs of this job (stamp plus optional same-second number), so
|
|
174
|
+
// "fix-login" never matches "fix-login-page".
|
|
175
|
+
const runs = listed.stdout.split('\n').map((name) => name.trim())
|
|
176
|
+
.map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
|
|
177
|
+
.filter(({ name, run }) => run && name !== skip)
|
|
178
|
+
.sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
|
|
179
|
+
if (!runs.length) return null;
|
|
180
|
+
const newest = runs[0].name;
|
|
181
|
+
const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
|
|
182
|
+
if (cherry.status !== 0) return null;
|
|
183
|
+
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// True only when git proves the branch no longer merges cleanly with the base.
|
|
187
|
+
// merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
|
|
188
|
+
// resume as before.
|
|
189
|
+
function conflictsWithBase(root, base, branch) {
|
|
190
|
+
const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
|
|
191
|
+
return result.status === 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
162
194
|
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
163
195
|
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
164
196
|
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
@@ -446,10 +478,30 @@ function statusCounts(root, {
|
|
|
446
478
|
|
|
447
479
|
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
448
480
|
// records engine output only. Paths that were never staged are simply not listed.
|
|
481
|
+
// GitHub refuses a merge with "Base branch was modified" when another PR
|
|
482
|
+
// merged into the same base a moment earlier; the same merge succeeds on a
|
|
483
|
+
// second try. On 2026-10-15 five parallel dispatches lost three landings to
|
|
484
|
+
// it. Retry only that message, a few times, with a short wait.
|
|
485
|
+
const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
|
|
486
|
+
function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
|
|
487
|
+
let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
488
|
+
for (let attempt = 1; attempt < attempts && merged.status !== 0
|
|
489
|
+
&& BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
|
|
490
|
+
console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
|
|
491
|
+
wait(1500 * attempt);
|
|
492
|
+
merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
|
|
493
|
+
}
|
|
494
|
+
return merged;
|
|
495
|
+
}
|
|
496
|
+
|
|
449
497
|
function unstageConductorArtifacts(root) {
|
|
450
498
|
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
451
499
|
if (staged.status !== 0) return [];
|
|
452
|
-
const
|
|
500
|
+
const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
|
|
501
|
+
const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
|
|
502
|
+
// A file this commit would add under .atris/ is the tool's own state, never work.
|
|
503
|
+
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
|
|
504
|
+
.filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
|
|
453
505
|
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
454
506
|
return drop;
|
|
455
507
|
}
|
|
@@ -529,8 +581,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
529
581
|
reapedBeforeStart = cleaned.removed;
|
|
530
582
|
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
531
583
|
} catch {}
|
|
532
|
-
|
|
533
|
-
|
|
584
|
+
let branch = branchOverride || branchName(owner, task, now);
|
|
585
|
+
let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
586
|
+
// Two runs of the same job in the same second get the same name; number
|
|
587
|
+
// the later one instead of failing it.
|
|
588
|
+
if (!pathOverride && !branchOverride) {
|
|
589
|
+
const baseBranch = branch;
|
|
590
|
+
const baseTarget = target;
|
|
591
|
+
for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
|
|
592
|
+
branch = `${baseBranch}-${n}`;
|
|
593
|
+
target = `${baseTarget}-${n}`;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
534
596
|
// Agent/member worktrees always cut from the mainline: honoring the
|
|
535
597
|
// launcher checkout's upstream let a dispatch inherit another session's
|
|
536
598
|
// in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
|
|
@@ -540,9 +602,24 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
540
602
|
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
541
603
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
542
604
|
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
605
|
+
// A retry of the same job starts from the last run's unlanded commits; it
|
|
606
|
+
// still ships to the mainline.
|
|
607
|
+
let resumedFrom = (!pathOverride && !branchOverride)
|
|
608
|
+
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
609
|
+
: null;
|
|
610
|
+
// A last run that now conflicts with the mainline would hand its conflict
|
|
611
|
+
// to the retry, which then cannot land on the first try. Start fresh; the
|
|
612
|
+
// old branch keeps its commits.
|
|
613
|
+
let skippedResume = null;
|
|
614
|
+
if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
|
|
615
|
+
skippedResume = resumedFrom;
|
|
616
|
+
resumedFrom = null;
|
|
617
|
+
console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
|
|
618
|
+
}
|
|
619
|
+
const startPoint = resumedFrom || checkoutBase;
|
|
543
620
|
const ref = `refs/heads/${branch}`;
|
|
544
621
|
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
545
|
-
const baseCommit = runGit(['rev-parse', '--verify', `${
|
|
622
|
+
const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
|
|
546
623
|
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
547
624
|
// With no local copy the start fails on its own; only a real cut gets the warning.
|
|
548
625
|
if (staleBase && startCommit) {
|
|
@@ -554,7 +631,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
554
631
|
if (registered) {
|
|
555
632
|
if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
|
|
556
633
|
|| registered.head !== startCommit) return;
|
|
557
|
-
const dirt = statusCounts(target, { ignoreUntracked:
|
|
634
|
+
const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
|
|
558
635
|
if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
|
|
559
636
|
// No engine has started. Normal removal still refuses unexpected dirt.
|
|
560
637
|
runGit(['worktree', 'remove', target], { cwd: root });
|
|
@@ -562,7 +639,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
562
639
|
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
563
640
|
};
|
|
564
641
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
565
|
-
const added = runGit(['worktree', 'add', '-b', branch, target,
|
|
642
|
+
const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
|
|
566
643
|
if (added.status === 0) break;
|
|
567
644
|
// Git can create the branch before failing to write its tracking config.
|
|
568
645
|
// Drop only that untouched ref, never an existing branch or a checkout.
|
|
@@ -572,6 +649,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
572
649
|
}
|
|
573
650
|
sleepSync(100 * (attempt + 1));
|
|
574
651
|
}
|
|
652
|
+
// A resumed branch takes in what the mainline gained since, or a job that
|
|
653
|
+
// keeps resuming drifts further behind every run until it clashes
|
|
654
|
+
// (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
|
|
655
|
+
// check above proved this merge is clean; if git still refuses, the run
|
|
656
|
+
// keeps the old start instead of failing.
|
|
657
|
+
if (resumedFrom) {
|
|
658
|
+
const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
|
|
659
|
+
if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
|
|
660
|
+
}
|
|
575
661
|
try {
|
|
576
662
|
runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
|
|
577
663
|
runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
|
|
@@ -587,6 +673,8 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
587
673
|
branch,
|
|
588
674
|
base: shipBase,
|
|
589
675
|
checkout_base: checkoutBase,
|
|
676
|
+
resumed_from: resumedFrom,
|
|
677
|
+
skipped_resume: skippedResume,
|
|
590
678
|
workspace_root: root,
|
|
591
679
|
created_at: now.toISOString(),
|
|
592
680
|
}, null, 2) + '\n',
|
|
@@ -596,7 +684,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
596
684
|
discardFailedStart();
|
|
597
685
|
throw error;
|
|
598
686
|
}
|
|
599
|
-
return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
687
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
600
688
|
}
|
|
601
689
|
|
|
602
690
|
function startWorktree(args) {
|
|
@@ -912,7 +1000,7 @@ function shipWorktree(args) {
|
|
|
912
1000
|
const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
|
|
913
1001
|
// Conductor plumbing must not make the checkout look dirty, or a flight where the
|
|
914
1002
|
// engine wrote nothing commits the fleet's own prompt file and calls it work.
|
|
915
|
-
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked:
|
|
1003
|
+
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
|
|
916
1004
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
917
1005
|
const dirty = counts.staged || counts.unstaged || counts.untracked;
|
|
918
1006
|
if (dirty && !message) {
|
|
@@ -952,7 +1040,7 @@ function shipWorktree(args) {
|
|
|
952
1040
|
// already present before the verifier ran, so counting it here blocks a clean ship.
|
|
953
1041
|
const afterVerify = dryRun
|
|
954
1042
|
? { staged: 0, unstaged: 0, untracked: 0 }
|
|
955
|
-
: statusCounts(root, { ignoreUntracked:
|
|
1043
|
+
: statusCounts(root, { ignoreUntracked: isConductorUntracked })
|
|
956
1044
|
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
957
1045
|
if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
|
|
958
1046
|
console.error(
|
|
@@ -992,7 +1080,7 @@ function shipWorktree(args) {
|
|
|
992
1080
|
throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
|
|
993
1081
|
}
|
|
994
1082
|
}
|
|
995
|
-
const primaryCounts = statusCounts(primary, { ignoreUntracked:
|
|
1083
|
+
const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorUntracked }) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
996
1084
|
if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
|
|
997
1085
|
throw new Error(
|
|
998
1086
|
`primary checkout dirty staged=${primaryCounts.staged} ` +
|
|
@@ -1023,7 +1111,7 @@ function shipWorktree(args) {
|
|
|
1023
1111
|
const mergeArgs = ['pr', 'merge'];
|
|
1024
1112
|
if (mergeRef) mergeArgs.push(mergeRef);
|
|
1025
1113
|
mergeArgs.push('--merge');
|
|
1026
|
-
const merged =
|
|
1114
|
+
const merged = mergePrWithRetry(mergeArgs, root);
|
|
1027
1115
|
if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
|
|
1028
1116
|
// `gh pr merge` on an already-merged PR exits 0 with a warning
|
|
1029
1117
|
// instead of failing, which would otherwise report a false landing.
|
|
@@ -1067,7 +1155,7 @@ function guard(args) {
|
|
|
1067
1155
|
// Conductor plumbing (worktree metadata, the fleet prompt written for this very
|
|
1068
1156
|
// engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
|
|
1069
1157
|
// harness blocks the engine with the harness's own files.
|
|
1070
|
-
const counts = statusCounts(root, { ignoreUntracked:
|
|
1158
|
+
const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
|
|
1071
1159
|
if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
|
|
1072
1160
|
console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
|
|
1073
1161
|
return 3;
|
|
@@ -1125,12 +1213,14 @@ function canonicalPath(value) {
|
|
|
1125
1213
|
}
|
|
1126
1214
|
}
|
|
1127
1215
|
|
|
1216
|
+
// null means the scan failed: we cannot tell who is inside, so callers keep
|
|
1217
|
+
// every folder. An empty list would read as "nobody is here" and delete.
|
|
1128
1218
|
function listActiveProcessCwds() {
|
|
1129
1219
|
const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
|
|
1130
1220
|
encoding: 'utf8',
|
|
1131
1221
|
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1132
1222
|
});
|
|
1133
|
-
if (result.status !== 0) return
|
|
1223
|
+
if (result.error || result.status !== 0) return null;
|
|
1134
1224
|
return String(result.stdout || '')
|
|
1135
1225
|
.split(/\r?\n/)
|
|
1136
1226
|
.filter((line) => line.startsWith('n'))
|
|
@@ -1138,7 +1228,45 @@ function listActiveProcessCwds() {
|
|
|
1138
1228
|
.filter(Boolean);
|
|
1139
1229
|
}
|
|
1140
1230
|
|
|
1231
|
+
// Absolute paths named on each program's command line, including flag values
|
|
1232
|
+
// like --require=/x.js. A program can run a script from a work folder while
|
|
1233
|
+
// its current folder is elsewhere; the folder scan alone misses it. The list
|
|
1234
|
+
// prints arguments unquoted, so a path with a space is also kept as the rest
|
|
1235
|
+
// of the line from where it starts; the folder match is a prefix check.
|
|
1236
|
+
function parseProcessArgPaths(psOutput) {
|
|
1237
|
+
const paths = new Set();
|
|
1238
|
+
const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
|
|
1239
|
+
for (const raw of String(psOutput || '').split(/\r?\n/)) {
|
|
1240
|
+
const line = raw.trim();
|
|
1241
|
+
for (const match of line.matchAll(starts)) {
|
|
1242
|
+
const rest = line.slice(match.index + match[0].length);
|
|
1243
|
+
const token = rest.split(/\s/, 1)[0];
|
|
1244
|
+
if (token.length > 1) paths.add(token);
|
|
1245
|
+
if (rest.length > token.length) paths.add(rest);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return [...paths];
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function listActiveProcessArgPaths() {
|
|
1252
|
+
const result = spawnSync('ps', ['-axww', '-o', 'args='], {
|
|
1253
|
+
encoding: 'utf8',
|
|
1254
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
1255
|
+
});
|
|
1256
|
+
if (result.error || result.status !== 0) return [];
|
|
1257
|
+
return parseProcessArgPaths(result.stdout);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Everything a running program is anchored to: its current folder plus any
|
|
1261
|
+
// script path on its command line.
|
|
1262
|
+
function listActiveProcessPaths() {
|
|
1263
|
+
const cwds = listActiveProcessCwds();
|
|
1264
|
+
if (!Array.isArray(cwds)) return cwds;
|
|
1265
|
+
return cwds.concat(listActiveProcessArgPaths());
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1141
1268
|
function worktreeHasActiveProcess(worktreePath, activeCwds) {
|
|
1269
|
+
if (!Array.isArray(activeCwds)) return true;
|
|
1142
1270
|
const resolved = canonicalPath(worktreePath);
|
|
1143
1271
|
const prefix = `${resolved}${path.sep}`;
|
|
1144
1272
|
return activeCwds.some((cwd) => {
|
|
@@ -1186,7 +1314,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1186
1314
|
const worktrees = listWorktrees(root);
|
|
1187
1315
|
const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
|
|
1188
1316
|
const current = path.resolve(root);
|
|
1189
|
-
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) :
|
|
1317
|
+
const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
|
|
1190
1318
|
const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
|
|
1191
1319
|
refreshRemoteRef(root, base);
|
|
1192
1320
|
const candidates = [];
|
|
@@ -1221,7 +1349,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
|
|
|
1221
1349
|
continue;
|
|
1222
1350
|
}
|
|
1223
1351
|
if (worktreeHasActiveProcess(wtPath, activeCwds)) {
|
|
1224
|
-
kept.push({ ...item, reason: 'active_process' });
|
|
1352
|
+
kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
1225
1353
|
continue;
|
|
1226
1354
|
}
|
|
1227
1355
|
const rawCounts = statusCounts(wt.path);
|
|
@@ -1401,6 +1529,7 @@ function worktreeCommand(args = []) {
|
|
|
1401
1529
|
module.exports = {
|
|
1402
1530
|
branchName,
|
|
1403
1531
|
collidingFlights,
|
|
1532
|
+
mergePrWithRetry,
|
|
1404
1533
|
createAgentWorktree,
|
|
1405
1534
|
deniedLaneForShip,
|
|
1406
1535
|
createOrFindPr,
|
|
@@ -1411,9 +1540,10 @@ module.exports = {
|
|
|
1411
1540
|
gitNetworkEnvironment,
|
|
1412
1541
|
describeFlightAge,
|
|
1413
1542
|
flightStampMs,
|
|
1414
|
-
|
|
1543
|
+
listActiveProcessPaths,
|
|
1415
1544
|
listWorktrees,
|
|
1416
1545
|
parseAgentFlightName,
|
|
1546
|
+
parseProcessArgPaths,
|
|
1417
1547
|
parseWorktrees,
|
|
1418
1548
|
normalizeTargetRef,
|
|
1419
1549
|
recordWorktreeRemoval,
|
package/commands/write.js
CHANGED
|
@@ -341,7 +341,17 @@ function coach(argv, root = process.cwd()) {
|
|
|
341
341
|
|
|
342
342
|
if (!offline && claudeAvailable()) {
|
|
343
343
|
const prompt = coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat: current.title });
|
|
344
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
345
|
+
// SIGKILL says whether the machine was short on memory.
|
|
346
|
+
const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'claude', task: 'write coach' });
|
|
344
347
|
const res = spawnSync('claude', ['-p', prompt], { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
|
|
348
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
349
|
+
const end = endResource(res.error || res.signal || res.status !== 0 ? {
|
|
350
|
+
code: res.error && res.error.code,
|
|
351
|
+
signal: res.signal,
|
|
352
|
+
stderr: res.stderr,
|
|
353
|
+
message: res.error ? res.error.message : `claude exited ${res.status}`,
|
|
354
|
+
} : null);
|
|
345
355
|
const out = (res.stdout || '').trim();
|
|
346
356
|
if (res.status === 0 && /QUESTION:/.test(out)) {
|
|
347
357
|
console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
@@ -350,7 +360,8 @@ function coach(argv, root = process.cwd()) {
|
|
|
350
360
|
console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
|
|
351
361
|
return 0;
|
|
352
362
|
}
|
|
353
|
-
|
|
363
|
+
const stopped = end && end.cause ? `claude coach stopped by ${res.signal}; ${end.cause}` : 'claude coach unavailable';
|
|
364
|
+
console.log(` (${stopped}, falling back to the offline coach)\n`);
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
// Offline coach: still proactive, still gets you going. Seeds + one question.
|
package/commands/youtube.js
CHANGED
|
@@ -1239,17 +1239,32 @@ function buildDigestPrompt(briefs = []) {
|
|
|
1239
1239
|
|
|
1240
1240
|
function defaultDigestRunner(prompt, deps = {}) {
|
|
1241
1241
|
const spawn = deps.spawnSync || spawnSync;
|
|
1242
|
-
|
|
1242
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
1243
|
+
// SIGKILL says whether the machine was short on memory.
|
|
1244
|
+
const endResource = require('../lib/engine-resource').startEngineRun(deps.cwd || process.cwd(), { engine: 'claude', task: 'youtube digest' });
|
|
1245
|
+
const result = spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
|
|
1243
1246
|
encoding: 'utf8',
|
|
1244
1247
|
timeout: DIGEST_ENGINE_TIMEOUT_MS,
|
|
1245
1248
|
maxBuffer: 10 * 1024 * 1024,
|
|
1246
1249
|
});
|
|
1250
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
1251
|
+
const failed = !result || result.error || result.signal || result.status !== 0;
|
|
1252
|
+
const end = endResource(failed ? {
|
|
1253
|
+
code: result && result.error && result.error.code,
|
|
1254
|
+
signal: result && result.signal,
|
|
1255
|
+
stderr: result && result.stderr,
|
|
1256
|
+
message: result && result.error ? result.error.message : `claude exited ${result && result.status}`,
|
|
1257
|
+
} : null);
|
|
1258
|
+
return end && end.cause && result && !(result.error && result.error.code === 'ETIMEDOUT') ? { ...result, cause: end.cause } : result;
|
|
1247
1259
|
}
|
|
1248
1260
|
|
|
1249
1261
|
function invokeDigestEngine(prompt, deps = {}) {
|
|
1250
1262
|
const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
|
|
1251
1263
|
const result = runner(prompt, deps);
|
|
1252
1264
|
if (typeof result === 'string') return result.trim();
|
|
1265
|
+
if (result && result.signal && !result.error) {
|
|
1266
|
+
throw new Error(`digest engine stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`);
|
|
1267
|
+
}
|
|
1253
1268
|
if (!result || result.error || (result.status != null && result.status !== 0)) {
|
|
1254
1269
|
const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
|
|
1255
1270
|
throw new Error(detail || 'digest engine failed');
|
|
@@ -1401,7 +1416,7 @@ function runYoutubeDigest(args = [], deps = {}) {
|
|
|
1401
1416
|
const prompt = buildDigestPrompt(briefs);
|
|
1402
1417
|
let text;
|
|
1403
1418
|
try {
|
|
1404
|
-
text = invokeDigestEngine(prompt, deps);
|
|
1419
|
+
text = invokeDigestEngine(prompt, { ...deps, cwd });
|
|
1405
1420
|
} catch (err) {
|
|
1406
1421
|
output(err.message || 'digest engine failed');
|
|
1407
1422
|
return 1;
|
package/lib/autoland.js
CHANGED
|
@@ -267,6 +267,8 @@ function acceptedInLastDay(tasks, now = Date.now()) {
|
|
|
267
267
|
result: String(t.result || t.metadata?.result || '').slice(0, 220),
|
|
268
268
|
happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
|
|
269
269
|
member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
|
|
270
|
+
// Accepted is a review verdict; only a passed landing proof is landed.
|
|
271
|
+
landed: t.metadata?.landing?.status === 'landed',
|
|
270
272
|
};
|
|
271
273
|
if (t.metadata?.auto_accepted_at) auto.push(entry);
|
|
272
274
|
else human.push(entry);
|
|
@@ -481,6 +483,9 @@ function unexplainedSummaryRows(items, label) {
|
|
|
481
483
|
if (label === 'landed') {
|
|
482
484
|
return [`${plur(count, 'landed result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
|
|
483
485
|
}
|
|
486
|
+
if (label === 'accepted') {
|
|
487
|
+
return [`${plur(count, 'accepted result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
|
|
488
|
+
}
|
|
484
489
|
if (label === 'in review') {
|
|
485
490
|
return [`${plur(count, 'review')} stayed hidden because ${count === 1 ? 'its next action was' : 'their next actions were'} unclear`];
|
|
486
491
|
}
|
|
@@ -547,6 +552,10 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
547
552
|
lines.push(`atris ${project}: last 24 hours`);
|
|
548
553
|
const autoCount = accepted.auto.length;
|
|
549
554
|
const humanCount = accepted.human.length;
|
|
555
|
+
const landedCount = accepted.auto.filter((item) => item.landed === true).length;
|
|
556
|
+
// "N landed" only when every one of them proved it; otherwise say both.
|
|
557
|
+
const finished = landedCount === autoCount ? `${autoCount} landed` : `${autoCount} accepted, ${landedCount} landed`;
|
|
558
|
+
const finishedWord = landedCount === autoCount ? 'landed' : 'accepted';
|
|
550
559
|
const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
|
|
551
560
|
.filter((wish) => wish && String(wish.text || '').trim());
|
|
552
561
|
if (autoCount > 0) {
|
|
@@ -564,8 +573,8 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
564
573
|
// The header states the actual bar: under accept-all the claim
|
|
565
574
|
// "verified twice" would be a lie the operator builds trust on.
|
|
566
575
|
lines.push(acceptAll
|
|
567
|
-
? `${
|
|
568
|
-
: `${
|
|
576
|
+
? `${finished}; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
|
|
577
|
+
: `${finished}; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
|
|
569
578
|
for (const { item, line } of visibleWork) {
|
|
570
579
|
lines.push('');
|
|
571
580
|
lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
|
|
@@ -577,9 +586,9 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
577
586
|
: `${plur(held, 'more result')} when you want them: atris autoland digest`);
|
|
578
587
|
}
|
|
579
588
|
} else {
|
|
580
|
-
lines.push(`${
|
|
589
|
+
lines.push(`${finished} with proof on file; no clear result was ready to show:`);
|
|
581
590
|
}
|
|
582
|
-
for (const row of unexplainedSummaryRows(unexplainedWork,
|
|
591
|
+
for (const row of unexplainedSummaryRows(unexplainedWork, finishedWord)) lines.push(row);
|
|
583
592
|
}
|
|
584
593
|
if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
|
|
585
594
|
if (waiting.length > 0) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Work and review in rounds (Astra's rule: let agents work 50 minutes, then
|
|
2
|
+
// take 10). In the work window the campaign loop runs: it plans, runs missions
|
|
3
|
+
// at once, and judges. In the break the judge runs if due, reserves are
|
|
4
|
+
// reconciled, and the owner writes a short review of the round into the
|
|
5
|
+
// campaign's lessons file, which the next round's planning reads. Each round
|
|
6
|
+
// leaves the campaign a little smarter than the last.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
13
|
+
|
|
14
|
+
const LESSON_LINES_FOR_PLANNING = 30;
|
|
15
|
+
|
|
16
|
+
function parseDuration(text, fallbackMs) {
|
|
17
|
+
if (text === undefined || text === null || text === '') return fallbackMs;
|
|
18
|
+
const match = /^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i.exec(String(text).trim());
|
|
19
|
+
if (!match) throw new Error(`durations look like 50m, 10m, 90s, or 1h (got ${text})`);
|
|
20
|
+
const unit = (match[2] || 'm').toLowerCase();
|
|
21
|
+
return Math.round(Number(match[1]) * { s: 1000, m: 60000, h: 3600000 }[unit]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lessonsPath(root, campaignId) {
|
|
25
|
+
return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The newest lessons, for the planner.
|
|
29
|
+
function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
|
|
30
|
+
try {
|
|
31
|
+
const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
|
|
32
|
+
return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
|
|
33
|
+
} catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function roundSummary(before, after) {
|
|
39
|
+
const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
|
|
40
|
+
const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
|
|
41
|
+
const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
|
|
42
|
+
const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
|
|
43
|
+
const spent = Math.round((after.budget.spent - before.budget.spent) * 100) / 100;
|
|
44
|
+
return { kept, failed, vetoed, spent, fresh };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reviewPrompt(view, summary, lessons) {
|
|
48
|
+
const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
|
|
49
|
+
return [
|
|
50
|
+
'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
|
|
51
|
+
'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
|
|
52
|
+
'',
|
|
53
|
+
`Campaign: ${view.title}`,
|
|
54
|
+
`Judge: ${view.judge}`,
|
|
55
|
+
`Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
|
|
56
|
+
`This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
|
|
57
|
+
'Missions this round:',
|
|
58
|
+
missions,
|
|
59
|
+
'',
|
|
60
|
+
'Lessons so far:',
|
|
61
|
+
lessons || '- none yet',
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCadence(root, loop, options = {}, deps = {}) {
|
|
66
|
+
const store = deps.store || openCampaignStore(root);
|
|
67
|
+
const log = deps.log || ((line) => console.log(line));
|
|
68
|
+
const now = deps.now || (() => Date.now());
|
|
69
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
70
|
+
const ask = deps.ask;
|
|
71
|
+
const workMs = parseDuration(options.work, 50 * 60000);
|
|
72
|
+
const breakMs = parseDuration(options.break, 10 * 60000);
|
|
73
|
+
const rounds = Number(options.rounds) > 0 ? Number(options.rounds) : Infinity;
|
|
74
|
+
|
|
75
|
+
async function review(id, before) {
|
|
76
|
+
const after = getCampaign(store, id);
|
|
77
|
+
const summary = roundSummary(before, after);
|
|
78
|
+
const lessons = readLessons(root, id, 200);
|
|
79
|
+
let written = [];
|
|
80
|
+
if (ask) {
|
|
81
|
+
const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
|
|
82
|
+
// A lesson already on file is not written twice.
|
|
83
|
+
const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
|
|
84
|
+
written = String((answer && answer.text) || '').split('\n').map((line) => line.trim())
|
|
85
|
+
.filter((line) => line.startsWith('- ') && !known.has(line.toLowerCase())).slice(0, 6);
|
|
86
|
+
}
|
|
87
|
+
const file = lessonsPath(root, id);
|
|
88
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
|
|
90
|
+
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
|
|
91
|
+
const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
|
|
92
|
+
fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
|
|
93
|
+
return { summary, lessons: written };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function run(id) {
|
|
97
|
+
let round = 0;
|
|
98
|
+
let last = { reason: 'not started' };
|
|
99
|
+
while (round < rounds) {
|
|
100
|
+
round += 1;
|
|
101
|
+
const before = getCampaign(store, id);
|
|
102
|
+
const until = now() + workMs;
|
|
103
|
+
log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
|
|
104
|
+
let stopped = null;
|
|
105
|
+
while (now() < until) {
|
|
106
|
+
const result = await loop.step(id);
|
|
107
|
+
if (result.stop) {
|
|
108
|
+
stopped = result.reason;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const breakStarted = now();
|
|
113
|
+
log(`round ${round}: review`);
|
|
114
|
+
const { summary, lessons } = await review(id, before);
|
|
115
|
+
log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
|
|
116
|
+
const view = getCampaign(store, id);
|
|
117
|
+
last = { reason: stopped || 'work window ended', round };
|
|
118
|
+
// Finished, out of money, or waiting on a person: no point in another round.
|
|
119
|
+
if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
|
|
120
|
+
return { rounds: round, reason: stopped || view.state, campaign: view };
|
|
121
|
+
}
|
|
122
|
+
const rest = breakMs - (now() - breakStarted);
|
|
123
|
+
if (round < rounds && rest > 0) await sleep(rest);
|
|
124
|
+
}
|
|
125
|
+
return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { run, review };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { lessonsPath, makeCadence, parseDuration, readLessons, reviewPrompt };
|