atris 3.34.0 → 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.
Files changed (53) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. 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
- const actors = new Set();
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 = false, minPasses = AGENT_CERTIFICATION_REVIEW_PASSES } = options;
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
- const tag = String(task.tag || '').toLowerCase();
269
- if (DENIED_TAGS.has(tag)) return { eligible: false, ref, reason: `denied_tag_${tag}` };
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
- const multiActor = actors.size >= 2;
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: 'needs_second_reviewer_or_third_pass',
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' : (highConfidence ? '3_passes' : '2_actors_2_passes'),
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 null;
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
- lines.push('landed on their own (verified twice, proof on file):');
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
- function dispatchToEngine({ task, engine, worktreePath, timeoutMs = 900000, runner = null }) {
94
- const prompt = buildFleetPrompt(task, { worktreePath });
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
- return m ? m[0].trim() : '';
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 || (() => {
@@ -295,13 +393,20 @@ async function runFleetFlight({
295
393
 
296
394
  // Claim + cut a worktree per assignment, then dispatch all in parallel.
297
395
  const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
298
- resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath }));
396
+ resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath, root }));
299
397
  }));
300
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] : [];
301
406
  const prepared = [];
302
407
  for (const entry of staffed) {
303
408
  cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
304
- 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]);
305
410
  const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
306
411
  if (!wt) {
307
412
  flight.paused.push({ task: entry.task.display_id, stage: 'worktree_start', detail: started.stderr.slice(0, 200) });
@@ -321,7 +426,7 @@ async function runFleetFlight({
321
426
  const rebased = landArrival({ worktreePath: entry.worktreePath });
322
427
  if (!rebased.ok) return rebased;
323
428
  const check = focusedCheck(entry.task) || 'git log -1 --oneline';
324
- const shipped = cli(['worktree', 'ship', '--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`, '--verify', check, '--merge'], entry.worktreePath);
429
+ const shipped = cli(fleetShipArgs(entry, check), entry.worktreePath);
325
430
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
326
431
  return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
327
432
  }
@@ -354,3 +459,139 @@ async function runFleetFlight({
354
459
  log('');
355
460
  return flight;
356
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
+ }
@@ -9,13 +9,18 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
 
12
- // "- **[2026-06-18] some-id** — pass — text..." -> { date, id, status, text }
12
+ // "- **[2026-06-18] some-id** — pass — text..." -> { date, id, status, text, resolved }
13
+ // A leading "[resolved]" marker (written when a lesson's detector passes) is
14
+ // stripped from the text and surfaced as `resolved` so the view can retire it.
13
15
  function parseLessons(text) {
14
16
  const out = [];
15
17
  for (const line of String(text || '').split('\n')) {
16
18
  const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[—:-]*\s*(pass|fail)?\s*[—:-]*\s*(.*)$/i);
17
19
  if (!m) continue;
18
- out.push({ date: m[1].trim(), id: m[2].trim(), status: (m[3] || '').toLowerCase(), text: m[4].trim() });
20
+ let body = m[4].trim();
21
+ const resolved = /\[resolved\]/i.test(body);
22
+ body = body.replace(/\[resolved\]\s*/i, '').trim();
23
+ out.push({ date: m[1].trim(), id: m[2].trim(), status: (m[3] || '').toLowerCase(), text: body, resolved });
19
24
  }
20
25
  return out; // append-only file: oldest first
21
26
  }
@@ -55,15 +60,18 @@ function buildMemorySpec(root = process.cwd(), opts = {}) {
55
60
  const theme = opts.theme || 'atris';
56
61
  const brand = { name: opts.brand || 'Atris', accent: '.' };
57
62
  const { lessons, learnings, tasks } = gatherMemory(root);
63
+ const openLessons = lessons.filter((l) => !l.resolved);
64
+ const resolvedCount = lessons.length - openLessons.length;
58
65
  const recent = lessons.slice(-8).reverse(); // newest first
59
66
 
67
+ const retired = resolvedCount ? `, ${plural(resolvedCount, 'lesson')} auto-resolved` : '';
60
68
  const slides = [];
61
69
  slides.push({
62
70
  type: 'title',
63
71
  headline: 'What the workspace **learned**',
64
- sub: `${plural(lessons.length, 'lesson')} and ${plural(learnings.length, 'note')} compounded into memory${tasks ? `, ${plural(tasks.done, 'task')} done` : ''}.`,
72
+ sub: `${plural(openLessons.length, 'open lesson')}${retired} and ${plural(learnings.length, 'note')} compounded into memory${tasks ? `, ${plural(tasks.done, 'task')} done` : ''}.`,
65
73
  });
66
- slides.push({ type: 'bignumber', number: String(lessons.length), label: 'lessons compounded into the workspace' });
74
+ slides.push({ type: 'bignumber', number: String(openLessons.length), label: `open lessons${resolvedCount ? ` (${resolvedCount} retired)` : ''}` });
67
75
 
68
76
  if (recent.length) {
69
77
  slides.push({
@@ -76,7 +84,8 @@ function buildMemorySpec(root = process.cwd(), opts = {}) {
76
84
  header: { title: 'Recent updates', meta: `${recent.length} shown` },
77
85
  rows: recent.map((l, i) => ({
78
86
  title: titleize(l.id), sub: l.date,
79
- value: l.status || 'note', sev: l.status === 'fail' ? 0 : (l.status === 'pass' ? 2 : 1),
87
+ value: l.resolved ? 'resolved' : (l.status || 'note'),
88
+ sev: l.resolved ? 2 : (l.status === 'fail' ? 0 : (l.status === 'pass' ? 2 : 1)),
80
89
  active: i === 0,
81
90
  })),
82
91
  },