atris 3.33.3 → 3.35.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/AGENTS.md +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +19 -0
- package/ax +475 -17
- package/bin/atris.js +206 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/loops.js +212 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +598 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +256 -13
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
const { spawnSync } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { taskProofState } = require('./task-proof');
|
|
6
|
+
const { taskProofState, taskProofExecutionState } = require('./task-proof');
|
|
7
|
+
const reviewIntegrity = require('./review-integrity');
|
|
7
8
|
|
|
8
9
|
const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
|
|
10
|
+
// Kept for compat with older callers/tests; the pass-count landing lane it
|
|
11
|
+
// once powered is gone. Passes alone never land work, an independent
|
|
12
|
+
// reviewer does.
|
|
9
13
|
const AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES = 3;
|
|
10
14
|
const DENIED_TAGS = new Set(['billing', 'deploy', 'feedback', 'voice', 'security', 'customer', 'external']);
|
|
11
15
|
|
|
@@ -182,13 +186,7 @@ function unmergedPullRequestBoundaryResult(ref, proof) {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
function distinctReviewActors(task) {
|
|
185
|
-
|
|
186
|
-
for (const event of task.events || []) {
|
|
187
|
-
if (!['proof_ready', 'reviewed'].includes(event.event_type)) continue;
|
|
188
|
-
const actor = event.actor || event.payload?.actor;
|
|
189
|
-
if (actor) actors.add(String(actor));
|
|
190
|
-
}
|
|
191
|
-
return actors;
|
|
189
|
+
return reviewIntegrity.reviewEventActors(task);
|
|
192
190
|
}
|
|
193
191
|
|
|
194
192
|
function parseVerifyCommand(verify) {
|
|
@@ -224,6 +222,10 @@ function parseVerifyCommand(verify) {
|
|
|
224
222
|
return { ok: true, argv };
|
|
225
223
|
}
|
|
226
224
|
|
|
225
|
+
function isAutoCertifyVerifyCommandAllowed(verify) {
|
|
226
|
+
return parseVerifyCommand(verify).ok;
|
|
227
|
+
}
|
|
228
|
+
|
|
227
229
|
function runVerifyCommand(verify, workspaceRoot) {
|
|
228
230
|
const parsed = parseVerifyCommand(verify);
|
|
229
231
|
if (!parsed.ok) return parsed;
|
|
@@ -254,7 +256,7 @@ function strictVerifyMissingResult(ref) {
|
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
function evaluateAutoAccept(task, options = {}) {
|
|
257
|
-
const { strictVerify =
|
|
259
|
+
const { strictVerify = true, minPasses = AGENT_CERTIFICATION_REVIEW_PASSES, acceptAll = false } = options;
|
|
258
260
|
const ref = task.display_id || task.legacy_ref || task.id;
|
|
259
261
|
if (task.status !== 'review') return { eligible: false, ref, reason: 'not_in_review' };
|
|
260
262
|
const metadata = task.metadata || {};
|
|
@@ -265,8 +267,45 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
265
267
|
}
|
|
266
268
|
if (metadata.auto_accepted_at) return { eligible: false, ref, reason: 'already_auto_accepted' };
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
// 'deploys', 'infra-deploy', and 'Billing ' are the same lanes as their
|
|
271
|
+
// exact-match cousins: match denied lanes on whole words with a plural
|
|
272
|
+
// strip, so a tag variant never slips money/deploy work past the human.
|
|
273
|
+
const tag = String(task.tag || '').trim().toLowerCase();
|
|
274
|
+
const deniedTag = [...DENIED_TAGS].find((d) =>
|
|
275
|
+
tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
|
|
276
|
+
if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
|
|
277
|
+
|
|
278
|
+
// accept-all: the protected lanes above are the only human gate. No
|
|
279
|
+
// certification, pass-count, reviewer, or proof-quality bar — but work
|
|
280
|
+
// is never marked done against evidence it isn't: a proof naming an
|
|
281
|
+
// unmerged draft PR still blocks, and a recorded check that FAILS still
|
|
282
|
+
// blocks (absence of a check does not).
|
|
283
|
+
if (acceptAll) {
|
|
284
|
+
const proof = latestProof(task);
|
|
285
|
+
if (proofHasUnmergedPullRequestBoundary(proof)) {
|
|
286
|
+
return unmergedPullRequestBoundaryResult(ref, proof);
|
|
287
|
+
}
|
|
288
|
+
const verify = metadata.verify;
|
|
289
|
+
if (verify) {
|
|
290
|
+
const verifyResult = runVerifyCommand(verify, task.workspace_root || process.cwd());
|
|
291
|
+
// A check that runs and fails blocks. So does a check whose worktree
|
|
292
|
+
// is gone — otherwise the daily reap converts "has a failing check"
|
|
293
|
+
// into "lands unchecked" the morning after it clears the worktree.
|
|
294
|
+
// A check that merely isn't in the runnable allowlist counts as no
|
|
295
|
+
// check at all.
|
|
296
|
+
if (verifyResult.reason === 'verify_failed' || verifyResult.reason === 'verify_worktree_missing') {
|
|
297
|
+
return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
eligible: true,
|
|
302
|
+
ref,
|
|
303
|
+
reason: 'accept_all_but_protected',
|
|
304
|
+
passes: reviewPassCount(task),
|
|
305
|
+
proof,
|
|
306
|
+
policy: 'all_but_protected',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
270
309
|
|
|
271
310
|
if (!isAgentCertified(task)) return { eligible: false, ref, reason: 'not_agent_certified' };
|
|
272
311
|
|
|
@@ -279,16 +318,28 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
279
318
|
}
|
|
280
319
|
const proofCheck = taskProofState(proof);
|
|
281
320
|
if (!proofCheck.ok) return { eligible: false, ref, reason: proofCheck.reason, proof };
|
|
321
|
+
if (!strictVerify) {
|
|
322
|
+
const executionCheck = taskProofExecutionState(proof);
|
|
323
|
+
if (!executionCheck.ok) {
|
|
324
|
+
return {
|
|
325
|
+
eligible: false,
|
|
326
|
+
ref,
|
|
327
|
+
reason: executionCheck.reason,
|
|
328
|
+
detail: executionCheck.detail,
|
|
329
|
+
next_action: 'run an allowed verifier with `atris task ready --verify` or keep strict auto-accept enabled so the verifier executes before landing',
|
|
330
|
+
proof,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
282
334
|
|
|
283
335
|
const actors = distinctReviewActors(task);
|
|
284
|
-
|
|
285
|
-
const highConfidence = passes >= AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES;
|
|
286
|
-
if (!multiActor && !highConfidence) {
|
|
336
|
+
if (!reviewIntegrity.hasIndependentReview(task)) {
|
|
287
337
|
return {
|
|
288
338
|
eligible: false,
|
|
289
339
|
ref,
|
|
290
|
-
reason: '
|
|
340
|
+
reason: 'needs_independent_reviewer',
|
|
291
341
|
passes,
|
|
342
|
+
builder: reviewIntegrity.taskBuilder(task),
|
|
292
343
|
actors: [...actors],
|
|
293
344
|
};
|
|
294
345
|
}
|
|
@@ -306,13 +357,11 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
306
357
|
return {
|
|
307
358
|
eligible: true,
|
|
308
359
|
ref,
|
|
309
|
-
reason: strictVerify
|
|
310
|
-
? 'certified_strict_verify'
|
|
311
|
-
: (highConfidence ? 'certified_high_confidence' : 'certified_multi_actor'),
|
|
360
|
+
reason: strictVerify ? 'certified_strict_verify' : 'certified_independent_review',
|
|
312
361
|
passes,
|
|
313
362
|
actors: [...actors],
|
|
314
363
|
proof,
|
|
315
|
-
policy: strictVerify ? 'strict_verify' :
|
|
364
|
+
policy: strictVerify ? 'strict_verify' : 'independent_reviewer',
|
|
316
365
|
};
|
|
317
366
|
}
|
|
318
367
|
|
|
@@ -321,6 +370,8 @@ module.exports = {
|
|
|
321
370
|
AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES,
|
|
322
371
|
DENIED_TAGS,
|
|
323
372
|
evaluateAutoAccept,
|
|
373
|
+
isAutoCertifyVerifyCommandAllowed,
|
|
374
|
+
isAgentCertified,
|
|
324
375
|
parseVerifyCommand,
|
|
325
376
|
runVerifyCommand,
|
|
326
377
|
};
|
package/lib/autoland.js
CHANGED
|
@@ -36,9 +36,23 @@ function writeJson(file, value) {
|
|
|
36
36
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Default is ON: agents are good enough that a growing review queue is
|
|
40
|
+
// latency, not safety — the denied tags keep the irreversible lanes human.
|
|
41
|
+
// A missing policy file means "on, owner inferred"; only an explicit
|
|
42
|
+
// `atris autoland off` (enabled: false) turns it off.
|
|
43
|
+
function inferOwner(root) {
|
|
44
|
+
const git = spawnSync('git', ['config', 'user.name'], { cwd: root, encoding: 'utf8' });
|
|
45
|
+
const name = git.status === 0 ? String(git.stdout || '').trim() : '';
|
|
46
|
+
return name || os.userInfo().username;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function defaultPolicy(root) {
|
|
50
|
+
return { enabled: true, enabled_by: inferOwner(root), enabled_at: null, default: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
function readPolicy(root) {
|
|
40
54
|
const policy = readJson(policyPath(root), null);
|
|
41
|
-
if (!policy || typeof policy !== 'object') return
|
|
55
|
+
if (!policy || typeof policy !== 'object') return defaultPolicy(root);
|
|
42
56
|
return policy;
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -218,6 +232,15 @@ function dejargon(line) {
|
|
|
218
232
|
.trim();
|
|
219
233
|
}
|
|
220
234
|
|
|
235
|
+
// The clarity gate in one call: strip what an operator can't act on (flag
|
|
236
|
+
// dashes, task ids, snake_case) and close the sentence on a whole clause, so
|
|
237
|
+
// any title or line borrowed from the queue reaches a human actionable at a
|
|
238
|
+
// glance. One sentence in, one operator-ready sentence out — content always
|
|
239
|
+
// ships, the gate only changes how it reads.
|
|
240
|
+
function clarify(text, max = 160) {
|
|
241
|
+
return finishThought(dejargon(text), max);
|
|
242
|
+
}
|
|
243
|
+
|
|
221
244
|
// The mission loop files a check-off every time it stops instead of
|
|
222
245
|
// inventing work; those all say the same thing, so they collapse into one line.
|
|
223
246
|
function isCleanStop(item) {
|
|
@@ -230,7 +253,7 @@ function isCleanStop(item) {
|
|
|
230
253
|
// because "7 things landed" tells the score, not the story — and it always
|
|
231
254
|
// ends with what's waiting and what to do next, because those are the only
|
|
232
255
|
// lines that ask anything of the reader.
|
|
233
|
-
function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
|
|
256
|
+
function composeDigest({ accepted, waiting, landed, project, nextMoves = [], acceptAll = false, reapError = null, janitor = null }) {
|
|
234
257
|
const lines = [];
|
|
235
258
|
lines.push(`atris ${project}: yesterday`);
|
|
236
259
|
const autoCount = accepted.auto.length;
|
|
@@ -250,7 +273,11 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
|
|
|
250
273
|
const visibleWork = [...ready, ...rough].slice(0, 3);
|
|
251
274
|
const held = Math.max(0, work.length - visibleWork.length) + (stops.length > 0 ? 1 : 0);
|
|
252
275
|
if (visibleWork.length > 0) {
|
|
253
|
-
|
|
276
|
+
// The header states the actual bar: under accept-all the claim
|
|
277
|
+
// "verified twice" would be a lie the operator builds trust on.
|
|
278
|
+
lines.push(acceptAll
|
|
279
|
+
? 'landed on their own (protected lanes held back for you):'
|
|
280
|
+
: 'landed on their own (verified twice, proof on file):');
|
|
254
281
|
for (const { item, line } of visibleWork) {
|
|
255
282
|
lines.push('');
|
|
256
283
|
lines.push(`- ${line || item.ref}${item.member ? ` (${item.member})` : ''}`);
|
|
@@ -273,6 +300,14 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
|
|
|
273
300
|
if (landed && landed.branches > 0) {
|
|
274
301
|
lines.push(`in the air: ${plur(landed.branches, 'piece')}${landed.due > 0 ? `, ${landed.due} overdue` : ', all fresh'} (atris land)`);
|
|
275
302
|
}
|
|
303
|
+
if (reapError) lines.push(`cleanup trouble: the daily sweep failed (${reapError}) — run: atris land --reap`);
|
|
304
|
+
// The janitor's day in one line: stale paused missions stopped and merged
|
|
305
|
+
// worktrees cleared since the last digest. Silence when it did nothing.
|
|
306
|
+
const missionsStopped = Number(janitor?.missions_stopped) || 0;
|
|
307
|
+
const worktreesReaped = Number(janitor?.worktrees_reaped) || 0;
|
|
308
|
+
if (missionsStopped > 0 || worktreesReaped > 0) {
|
|
309
|
+
lines.push(`tidied up: ${plur(missionsStopped, 'stale mission')} stopped, ${plur(worktreesReaped, 'merged worktree')} cleared`);
|
|
310
|
+
}
|
|
276
311
|
// nextMoves: array of {title, owner}, or {moves, unexplained} where
|
|
277
312
|
// unexplained counts queue items whose sentence carries no operator why.
|
|
278
313
|
// Those are counted, not shown — a raw title the reader can't act on is
|
|
@@ -367,6 +402,7 @@ module.exports = {
|
|
|
367
402
|
DEFAULT_DIGEST_HOUR,
|
|
368
403
|
acceptedInLastDay,
|
|
369
404
|
buildCronLine,
|
|
405
|
+
clarify,
|
|
370
406
|
composeAlarm,
|
|
371
407
|
composeDigest,
|
|
372
408
|
composeLiveUpdate,
|
package/lib/fleet.js
CHANGED
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { spawnSync } = require('child_process');
|
|
17
17
|
const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
|
|
18
|
+
const { listWorktrees } = require('../commands/worktree');
|
|
18
19
|
|
|
19
20
|
// Lanes a fleet may never staff on its own: the human keeps irreversible
|
|
20
21
|
// calls. Mirrors the autoland denied lanes.
|
|
@@ -43,6 +44,8 @@ function buildFleetPrompt(task, { worktreePath } = {}) {
|
|
|
43
44
|
const title = String(task.title || '').trim();
|
|
44
45
|
const { done, check } = parseDoneCheck(title);
|
|
45
46
|
const lines = [
|
|
47
|
+
'First, run `atris worktree guard`; if it fails, stop immediately, report back, and do not edit anything. Do this before any file edit.',
|
|
48
|
+
'',
|
|
46
49
|
`You are working task ${ref} in this repo checkout (an isolated git worktree${worktreePath ? ` at ${worktreePath}` : ''} — commit here, NEVER push).`,
|
|
47
50
|
'',
|
|
48
51
|
`Task: ${title}`,
|
|
@@ -70,6 +73,28 @@ function buildFleetPrompt(task, { worktreePath } = {}) {
|
|
|
70
73
|
// their own permissions.
|
|
71
74
|
const FLEET_ALLOWED_TOOLS = 'Bash,Read,Edit,Write,Grep,Glob';
|
|
72
75
|
|
|
76
|
+
function realpathOrResolve(value) {
|
|
77
|
+
const resolved = path.resolve(String(value));
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(resolved);
|
|
80
|
+
} catch {
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertIsolatedWorktree(worktreePath, root = process.cwd()) {
|
|
86
|
+
if (!worktreePath) {
|
|
87
|
+
throw new Error('fleet dispatch blocked: worktreePath is required; refusing to dispatch without an isolated worktree');
|
|
88
|
+
}
|
|
89
|
+
const resolvedWorktree = realpathOrResolve(worktreePath);
|
|
90
|
+
const worktrees = listWorktrees(root);
|
|
91
|
+
const primaryRoot = realpathOrResolve(worktrees[0]?.path || root);
|
|
92
|
+
if (resolvedWorktree === primaryRoot) {
|
|
93
|
+
throw new Error(`fleet dispatch blocked: worktreePath resolves to the primary repo checkout (${primaryRoot}); refusing to dispatch outside an isolated worktree`);
|
|
94
|
+
}
|
|
95
|
+
return { worktreePath: resolvedWorktree, primaryRoot };
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
function buildEngineCommand(engineName, promptFile) {
|
|
74
99
|
if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
|
|
75
100
|
const prev = process.env.ATRIS_RUNNER_PROFILE;
|
|
@@ -89,9 +114,12 @@ function buildEngineCommand(engineName, promptFile) {
|
|
|
89
114
|
|
|
90
115
|
// Run one engine on one task in one worktree. Blocking; the conductor runs
|
|
91
116
|
// dispatches in parallel via child processes, not threads. `runner` is
|
|
92
|
-
// injectable for tests.
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
// injectable for tests. `prompt` is injectable too: a caller-supplied prompt
|
|
118
|
+
// (e.g. `atris engine dispatch --prompt-file`) skips the generated
|
|
119
|
+
// buildFleetPrompt text entirely.
|
|
120
|
+
function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), timeoutMs = 900000, runner = null, prompt: promptOverride = '' }) {
|
|
121
|
+
assertIsolatedWorktree(worktreePath, root);
|
|
122
|
+
const prompt = promptOverride || buildFleetPrompt(task, { worktreePath });
|
|
95
123
|
const promptFile = path.join(worktreePath, '.atris', `fleet-prompt-${task.display_id || 'task'}.md`);
|
|
96
124
|
fs.mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
97
125
|
fs.writeFileSync(promptFile, prompt);
|
|
@@ -118,12 +146,24 @@ function dispatchToEngine({ task, engine, worktreePath, timeoutMs = 900000, runn
|
|
|
118
146
|
|
|
119
147
|
function taskTags(task) {
|
|
120
148
|
const fromTags = Array.isArray(task.tags) ? task.tags : [];
|
|
149
|
+
// Tags added after creation live in metadata.tags (`atris task tag`); a
|
|
150
|
+
// fleet that only read task.tags/title hashtags would ignore an owner-hold
|
|
151
|
+
// flag stamped on a live task and keep restaffing it (CLI-879).
|
|
152
|
+
const fromMeta = task && task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
|
|
121
153
|
const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
|
|
122
|
-
return [...fromTags, ...fromTitle].map((t) => String(t).toLowerCase());
|
|
154
|
+
return [...fromTags, ...fromMeta, ...fromTitle].map((t) => String(t).toLowerCase());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A task flagged for a human decision is never fleet-staffable, whatever its
|
|
158
|
+
// lane. Mirrors the sweep's needs-human hold so both loops agree.
|
|
159
|
+
function isHumanHoldTag(tag) {
|
|
160
|
+
const normalized = String(tag).trim().toLowerCase().replace(/_/g, '-');
|
|
161
|
+
return normalized === 'needs-human' || normalized === 'needshuman';
|
|
123
162
|
}
|
|
124
163
|
|
|
125
164
|
function isSafeLane(task) {
|
|
126
165
|
const tags = taskTags(task);
|
|
166
|
+
if (tags.some(isHumanHoldTag)) return false;
|
|
127
167
|
return !tags.some((t) => DENIED_TAGS.includes(t));
|
|
128
168
|
}
|
|
129
169
|
|
|
@@ -177,6 +217,20 @@ function assignEngines(staffed, engines) {
|
|
|
177
217
|
// Land one arrival: rebase onto latest base first; a conflict pauses the
|
|
178
218
|
// landing (never auto-resolve) and reports which files collided so the
|
|
179
219
|
// conductor or a human can take it. `git` is injectable for tests.
|
|
220
|
+
// Fleet landings always target master. Without the explicit --target, ship
|
|
221
|
+
// falls back to the launcher branch's atris-base — a flight launched from a
|
|
222
|
+
// feature-branch checkout would merge PRs into that branch while the receipt
|
|
223
|
+
// and land board define "landed" as in-master (PRs #207/#208, 2026-07-04).
|
|
224
|
+
function fleetShipArgs(entry, check) {
|
|
225
|
+
return [
|
|
226
|
+
'worktree', 'ship',
|
|
227
|
+
'--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`,
|
|
228
|
+
'--verify', check,
|
|
229
|
+
'--target', 'origin/master',
|
|
230
|
+
'--merge',
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
180
234
|
function landArrival({ worktreePath, git = null }) {
|
|
181
235
|
const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
|
|
182
236
|
const fetch = run(['fetch', 'origin']);
|
|
@@ -199,17 +253,22 @@ module.exports = {
|
|
|
199
253
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
200
254
|
get runFleetFlight() { return runFleetFlight; },
|
|
201
255
|
get focusedCheck() { return focusedCheck; },
|
|
256
|
+
get dispatchCheck() { return dispatchCheck; },
|
|
257
|
+
get runDispatchFlight() { return runDispatchFlight; },
|
|
202
258
|
parseDoneCheck,
|
|
203
259
|
buildFleetPrompt,
|
|
260
|
+
assertIsolatedWorktree,
|
|
204
261
|
buildEngineCommand,
|
|
205
262
|
dispatchToEngine,
|
|
206
263
|
taskTags,
|
|
264
|
+
isHumanHoldTag,
|
|
207
265
|
isSafeLane,
|
|
208
266
|
fileSurface,
|
|
209
267
|
surfacesOverlap,
|
|
210
268
|
staffFlight,
|
|
211
269
|
assignEngines,
|
|
212
270
|
landArrival,
|
|
271
|
+
fleetShipArgs,
|
|
213
272
|
};
|
|
214
273
|
|
|
215
274
|
// ---------------------------------------------------------------------------
|
|
@@ -245,8 +304,46 @@ function defaultOwnCli(root) {
|
|
|
245
304
|
// gates (npm test, CI) stay at the ship gate, not per-arrival.
|
|
246
305
|
function focusedCheck(task) {
|
|
247
306
|
const { check } = parseDoneCheck(task.title);
|
|
248
|
-
const m = check.match(/node --test
|
|
249
|
-
|
|
307
|
+
const m = check.match(/node --test \S+/);
|
|
308
|
+
if (!m) return '';
|
|
309
|
+
let cmd = m[0].trim();
|
|
310
|
+
// Board convention often ends the Check: sentence with a trailing period
|
|
311
|
+
// right after the test file path (e.g. "node --test test/x.test.js."). A
|
|
312
|
+
// naive [^.]+ stop would also truncate the ".test.js" extension itself, so
|
|
313
|
+
// only strip the LAST period, and only when the path still ends in a real
|
|
314
|
+
// extension without it (otherwise the period is part of the path).
|
|
315
|
+
if (cmd.endsWith('.')) {
|
|
316
|
+
const stripped = cmd.slice(0, -1);
|
|
317
|
+
if (/\.(js|mjs|cjs|ts|tsx)$/.test(stripped)) cmd = stripped;
|
|
318
|
+
}
|
|
319
|
+
return cmd;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// One-command dispatch runs a single explicit task, so the wide-gate caution
|
|
323
|
+
// behind focusedCheck (many staffed tasks, keep per-arrival checks narrow)
|
|
324
|
+
// does not apply: fall back to the task's whole Check: text so the operator's
|
|
325
|
+
// actual check command is what gets re-run as verification.
|
|
326
|
+
function dispatchCheck(task) {
|
|
327
|
+
const narrow = focusedCheck(task);
|
|
328
|
+
if (narrow) return narrow;
|
|
329
|
+
const { check } = parseDoneCheck(task.title);
|
|
330
|
+
return check;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readTaskById(cli, taskId) {
|
|
334
|
+
const result = cli(['task', 'show', String(taskId), '--json']);
|
|
335
|
+
if (!result || result.status !== 0) return null;
|
|
336
|
+
try {
|
|
337
|
+
const parsed = JSON.parse(result.stdout);
|
|
338
|
+
return parsed && parsed.display_id ? parsed : null;
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function defaultVerifyRunner(command, cwd) {
|
|
345
|
+
const result = spawnSync(command, { cwd, encoding: 'utf8', shell: true });
|
|
346
|
+
return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
250
347
|
}
|
|
251
348
|
|
|
252
349
|
// One flight. Staff -> dispatch in parallel -> land serially -> receipt.
|
|
@@ -261,6 +358,7 @@ async function runFleetFlight({
|
|
|
261
358
|
ownCli = null,
|
|
262
359
|
dispatcher = null,
|
|
263
360
|
lander = null,
|
|
361
|
+
checkoutBase = 'origin/master',
|
|
264
362
|
} = {}) {
|
|
265
363
|
const cli = ownCli || defaultOwnCli(root);
|
|
266
364
|
const roster = engines || (() => {
|
|
@@ -269,6 +367,9 @@ async function runFleetFlight({
|
|
|
269
367
|
})();
|
|
270
368
|
|
|
271
369
|
const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
|
|
370
|
+
// The receipt path is decided BEFORE landings so each task's ready-proof
|
|
371
|
+
// can cite it — the proof policy certifies receipt-backed proofs agent-side.
|
|
372
|
+
const receiptPath = path.join(root, 'atris', 'runs', `fleet-${nowStamp()}.json`);
|
|
272
373
|
const flight = {
|
|
273
374
|
at: new Date().toISOString(),
|
|
274
375
|
root,
|
|
@@ -292,13 +393,20 @@ async function runFleetFlight({
|
|
|
292
393
|
|
|
293
394
|
// Claim + cut a worktree per assignment, then dispatch all in parallel.
|
|
294
395
|
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
295
|
-
resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath }));
|
|
396
|
+
resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath, root }));
|
|
296
397
|
}));
|
|
297
398
|
|
|
399
|
+
// Cut every build worktree from origin/master by default, not the launcher's
|
|
400
|
+
// HEAD. A flight launched from a long-lived feature-branch checkout would
|
|
401
|
+
// otherwise stack all that branch's commits onto the build branch, so
|
|
402
|
+
// rebase-before-ship replays them onto master and pauses at rebase_conflict
|
|
403
|
+
// (three backend pauses on members.py, 2026-07-05). --base overrides only
|
|
404
|
+
// when the operator explicitly wants launcher-HEAD.
|
|
405
|
+
const startBaseArgs = checkoutBase ? ['--base', checkoutBase] : [];
|
|
298
406
|
const prepared = [];
|
|
299
407
|
for (const entry of staffed) {
|
|
300
408
|
cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
|
|
301
|
-
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}
|
|
409
|
+
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`, ...startBaseArgs]);
|
|
302
410
|
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
303
411
|
if (!wt) {
|
|
304
412
|
flight.paused.push({ task: entry.task.display_id, stage: 'worktree_start', detail: started.stderr.slice(0, 200) });
|
|
@@ -318,7 +426,7 @@ async function runFleetFlight({
|
|
|
318
426
|
const rebased = landArrival({ worktreePath: entry.worktreePath });
|
|
319
427
|
if (!rebased.ok) return rebased;
|
|
320
428
|
const check = focusedCheck(entry.task) || 'git log -1 --oneline';
|
|
321
|
-
const shipped = cli(
|
|
429
|
+
const shipped = cli(fleetShipArgs(entry, check), entry.worktreePath);
|
|
322
430
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
323
431
|
return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
|
|
324
432
|
}
|
|
@@ -335,7 +443,7 @@ async function runFleetFlight({
|
|
|
335
443
|
const landed = land({ entry, result });
|
|
336
444
|
if (landed.ok) {
|
|
337
445
|
flight.landed.push({ task: entry.task.display_id, engine: entry.engine });
|
|
338
|
-
cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
|
|
446
|
+
cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
|
|
339
447
|
log(` ${entry.engine.padEnd(8)} ✓ landed ${entry.task.display_id}`);
|
|
340
448
|
} else {
|
|
341
449
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, ...landed });
|
|
@@ -343,12 +451,147 @@ async function runFleetFlight({
|
|
|
343
451
|
}
|
|
344
452
|
}
|
|
345
453
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
flight.receipt = path.join(receiptDir, `fleet-${nowStamp()}.json`);
|
|
454
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
455
|
+
flight.receipt = receiptPath;
|
|
349
456
|
fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
|
|
350
457
|
log('');
|
|
351
458
|
log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
|
|
352
459
|
log('');
|
|
353
460
|
return flight;
|
|
354
461
|
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// T5 — one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
|
|
465
|
+
|
|
466
|
+
// The manual version of this loop took 6 Bash calls per task the night this
|
|
467
|
+
// was written: claim, worktree start, prompt file, engine -p, verify, ship.
|
|
468
|
+
// One or more explicit task ids build in parallel isolated worktrees on ONE
|
|
469
|
+
// named engine; landings are serial, reusing the same rebase-before-ship,
|
|
470
|
+
// never-auto-resolve contract as the fleet. Unlike the fleet, dispatch
|
|
471
|
+
// re-runs the task's own Check: command directly (not just via the ship
|
|
472
|
+
// gate) and captures its real output for the ready proof, so proof text cites
|
|
473
|
+
// an actual re-runnable verifier instead of an engine's self-report.
|
|
474
|
+
async function runDispatchFlight({
|
|
475
|
+
root = process.cwd(),
|
|
476
|
+
taskIds = [],
|
|
477
|
+
engine,
|
|
478
|
+
prompt: promptOverride = '',
|
|
479
|
+
log = console.log,
|
|
480
|
+
ownCli = null,
|
|
481
|
+
dispatcher = null,
|
|
482
|
+
lander = null,
|
|
483
|
+
verifier = null,
|
|
484
|
+
rebase = null,
|
|
485
|
+
checkoutBase = 'origin/master',
|
|
486
|
+
} = {}) {
|
|
487
|
+
if (!engine) throw new Error('runDispatchFlight: engine is required');
|
|
488
|
+
if (!FLEET_CAPABLE.includes(engine)) {
|
|
489
|
+
throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${FLEET_CAPABLE.join(', ')})`);
|
|
490
|
+
}
|
|
491
|
+
const ids = [...new Set((taskIds || []).map((id) => String(id).trim()).filter(Boolean))];
|
|
492
|
+
if (!ids.length) throw new Error('runDispatchFlight: at least one task id is required');
|
|
493
|
+
if (promptOverride && ids.length > 1) {
|
|
494
|
+
throw new Error('runDispatchFlight: --prompt-file only supports a single task id');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const cli = ownCli || defaultOwnCli(root);
|
|
498
|
+
const verify = verifier || defaultVerifyRunner;
|
|
499
|
+
const receiptPath = path.join(root, 'atris', 'runs', `dispatch-${nowStamp()}.json`);
|
|
500
|
+
const flight = { at: new Date().toISOString(), root, engine, tasks: ids, results: [], landed: [], paused: [] };
|
|
501
|
+
|
|
502
|
+
log('');
|
|
503
|
+
log(` dispatch — ${ids.length} task${ids.length === 1 ? '' : 's'} -> ${engine}`);
|
|
504
|
+
log('');
|
|
505
|
+
|
|
506
|
+
// Same rule as the fleet: dispatch worktrees cut from origin/master by
|
|
507
|
+
// default so rebase-before-ship never replays a launcher feature branch.
|
|
508
|
+
const startBaseArgs = checkoutBase ? ['--base', checkoutBase] : [];
|
|
509
|
+
const prepared = [];
|
|
510
|
+
for (const taskId of ids) {
|
|
511
|
+
const task = readTaskById(cli, taskId);
|
|
512
|
+
if (!task) {
|
|
513
|
+
flight.paused.push({ task: taskId, stage: 'task_lookup', detail: 'task not found' });
|
|
514
|
+
log(` ✗ ${taskId} not found`);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
cli(['task', 'claim', taskId, '--as', `fleet-${engine}`]);
|
|
518
|
+
const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
|
|
519
|
+
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
520
|
+
if (!wt) {
|
|
521
|
+
flight.paused.push({ task: taskId, stage: 'worktree_start', detail: String(started.stderr || '').slice(0, 200) });
|
|
522
|
+
log(` ✗ ${taskId} worktree start failed`);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
prepared.push({ task, taskId, worktreePath: wt.trim() });
|
|
526
|
+
log(` building ${taskId} in ${path.basename(wt.trim())}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
530
|
+
resolve(dispatchToEngine({
|
|
531
|
+
task: entry.task,
|
|
532
|
+
engine,
|
|
533
|
+
worktreePath: entry.worktreePath,
|
|
534
|
+
root,
|
|
535
|
+
prompt: promptOverride || undefined,
|
|
536
|
+
}));
|
|
537
|
+
}));
|
|
538
|
+
|
|
539
|
+
const results = await Promise.all(prepared.map((entry) =>
|
|
540
|
+
dispatch(entry).then((r) => ({ entry, result: r })).catch((err) => ({ entry, result: { exitCode: 1, report: '', stderr: String(err && err.message || err) } }))
|
|
541
|
+
));
|
|
542
|
+
flight.results = results.map(({ entry, result }) => ({ task: entry.taskId, exitCode: result.exitCode }));
|
|
543
|
+
|
|
544
|
+
// Land serially: rebase, re-run Check: for real, ship gate re-verifies,
|
|
545
|
+
// conflict/verify failure pauses (never auto-resolve).
|
|
546
|
+
const rebaseArrival = rebase || landArrival;
|
|
547
|
+
const land = lander || (({ entry }) => {
|
|
548
|
+
const rebased = rebaseArrival({ worktreePath: entry.worktreePath });
|
|
549
|
+
if (!rebased.ok) return rebased;
|
|
550
|
+
const check = dispatchCheck(entry.task) || 'git log -1 --oneline';
|
|
551
|
+
const verified = verify(check, entry.worktreePath);
|
|
552
|
+
if (verified.status !== 0) {
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
stage: 'verify_failed',
|
|
556
|
+
detail: `${verified.stdout}${verified.stderr}`.slice(-500),
|
|
557
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const shipped = cli(fleetShipArgs({ task: entry.task, engine }, check), entry.worktreePath);
|
|
561
|
+
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
562
|
+
return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-500) };
|
|
563
|
+
}
|
|
564
|
+
return { ok: true, stage: 'shipped', check, verifyOutput: `${verified.stdout}${verified.stderr}` };
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
for (const { entry, result } of results) {
|
|
568
|
+
if (result.exitCode !== 0) {
|
|
569
|
+
flight.paused.push({ task: entry.taskId, stage: 'build', detail: String(result.stderr || '').slice(0, 300) });
|
|
570
|
+
log(` ✗ build failed ${entry.taskId} — worktree kept for takeover`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
log(` landing ${entry.taskId}...`);
|
|
574
|
+
const landed = land({ entry, result });
|
|
575
|
+
if (landed.ok) {
|
|
576
|
+
flight.landed.push({ task: entry.taskId, engine, check: landed.check });
|
|
577
|
+
const verifyTail = String(landed.verifyOutput || '').trim().slice(-1200).replace(/\n/g, ' ');
|
|
578
|
+
cli([
|
|
579
|
+
'task', 'ready', entry.taskId,
|
|
580
|
+
'--proof', `Built by ${engine} engine via atris engine dispatch, landed via worktree ship gate (rebase-before-ship, verify re-run). Check re-run: ${landed.check}. Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
581
|
+
'--as', `fleet-${engine}`,
|
|
582
|
+
]);
|
|
583
|
+
log(` ✓ landed ${entry.taskId}`);
|
|
584
|
+
} else {
|
|
585
|
+
flight.paused.push({ task: entry.taskId, engine, ...landed });
|
|
586
|
+
log(` ⏸ paused ${entry.taskId} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
591
|
+
flight.receipt = receiptPath;
|
|
592
|
+
fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
|
|
593
|
+
log('');
|
|
594
|
+
log(` dispatch over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
|
|
595
|
+
log('');
|
|
596
|
+
return flight;
|
|
597
|
+
}
|