atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
@@ -3,6 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
+ const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
7
+
8
+ const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
+ const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
10
+ const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
11
+ const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
6
12
 
7
13
  function runGit(args, { cwd = process.cwd(), check = true } = {}) {
8
14
  const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
@@ -14,7 +20,14 @@ function runGit(args, { cwd = process.cwd(), check = true } = {}) {
14
20
  }
15
21
 
16
22
  function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
17
- const result = spawnSync(command, { cwd, encoding: 'utf8', shell: true, stdio: 'pipe' });
23
+ const result = spawnSync(command, {
24
+ cwd,
25
+ encoding: 'utf8',
26
+ shell: true,
27
+ stdio: 'pipe',
28
+ maxBuffer: COMMAND_MAX_BUFFER_BYTES,
29
+ });
30
+ if (result.error) throw result.error;
18
31
  if (check && result.status !== 0) {
19
32
  const msg = (result.stderr || result.stdout || `${command} failed`).trim();
20
33
  throw new Error(msg);
@@ -22,6 +35,67 @@ function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
22
35
  return result;
23
36
  }
24
37
 
38
+ function statusSnapshot(root) {
39
+ const result = runGit(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, check: false });
40
+ if (result.status !== 0) return [];
41
+ return result.stdout
42
+ .split(/\r?\n/)
43
+ .map((line) => line.trimEnd())
44
+ .filter(Boolean);
45
+ }
46
+
47
+ function newStatusRows(before, after) {
48
+ const seen = new Set(before);
49
+ return after.filter((row) => !seen.has(row));
50
+ }
51
+
52
+ function readOneLapProofBinding(root, branch) {
53
+ const sidecarPath = path.join(root, '.atris', 'agent-worktree.json');
54
+ let sidecar = null;
55
+ try {
56
+ sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
57
+ } catch (error) {
58
+ if (error && error.code !== 'ENOENT') throw new Error('one-lap proof metadata is unreadable');
59
+ }
60
+ const metadata = sidecar && sidecar.one_lap_proof;
61
+ const configured = runGit(['config', '--get', `branch.${branch}.atris-proof-ref`], { cwd: root, check: false });
62
+ const configuredRef = configured.status === 0 ? String(configured.stdout || '').trim() : '';
63
+ if (!metadata && !configuredRef) return null;
64
+ if (!metadata || !configuredRef) throw new Error('one-lap proof binding is incomplete');
65
+
66
+ const proofRef = String(metadata.ref || '');
67
+ const sourceCommit = String(metadata.commit || '');
68
+ const sourceTree = String(metadata.tree || '');
69
+ const refMatch = proofRef.match(ONE_LAP_PROOF_REF_PATTERN);
70
+ if (!refMatch || proofRef !== configuredRef || !GIT_OID_PATTERN.test(sourceCommit) || !GIT_OID_PATTERN.test(sourceTree)) {
71
+ throw new Error('one-lap proof binding is invalid');
72
+ }
73
+ const resolvedCommit = runGit(['rev-parse', '--verify', `${proofRef}^{commit}`], { cwd: root, check: false });
74
+ const resolvedTree = runGit(['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: root, check: false });
75
+ if (resolvedCommit.status !== 0 || String(resolvedCommit.stdout || '').trim() !== sourceCommit || refMatch[1] !== sourceCommit) {
76
+ throw new Error('one-lap proof commit no longer matches its retained ref');
77
+ }
78
+ if (resolvedTree.status !== 0 || String(resolvedTree.stdout || '').trim() !== sourceTree) {
79
+ throw new Error('one-lap proof tree no longer matches its retained ref');
80
+ }
81
+ return { ref: proofRef, commit: sourceCommit, tree: sourceTree };
82
+ }
83
+
84
+ function worktreeTree(root, source) {
85
+ const result = runGit(source === 'index'
86
+ ? ['write-tree']
87
+ : ['rev-parse', '--verify', 'HEAD^{tree}'], { cwd: root, check: false });
88
+ if (result.status !== 0) throw new Error(`could not resolve the ${source} tree for one-lap proof`);
89
+ return String(result.stdout || '').trim();
90
+ }
91
+
92
+ function assertOneLapProofTree(root, binding, source) {
93
+ if (!binding) return;
94
+ if (worktreeTree(root, source) !== binding.tree) {
95
+ throw new Error(`one-lap ${source} tree differs from the independently verified proof tree`);
96
+ }
97
+ }
98
+
25
99
  function repoRoot(cwd = process.cwd()) {
26
100
  return runGit(['rev-parse', '--show-toplevel'], { cwd }).stdout.trim();
27
101
  }
@@ -89,6 +163,25 @@ function currentBranch(root = repoRoot()) {
89
163
  return runGit(['branch', '--show-current'], { cwd: root, check: false }).stdout.trim();
90
164
  }
91
165
 
166
+ function detachedShipBranch(root) {
167
+ return `codex/${path.basename(root)}`;
168
+ }
169
+
170
+ function createDetachedShipBranch(root, branch) {
171
+ if (refExists(root, `refs/heads/${branch}`) || refExists(root, `refs/remotes/origin/${branch}`)) {
172
+ console.error(`blocked: detached head branch already exists: ${branch}`);
173
+ return false;
174
+ }
175
+ const created = runGit(['switch', '-c', branch], { cwd: root, check: false });
176
+ if (created.status !== 0) {
177
+ console.error(`blocked: detached head branch create failed: ${branch}`);
178
+ console.error((created.stderr || created.stdout || '').trim());
179
+ return false;
180
+ }
181
+ console.log(`ship: detached head, created branch ${branch}`);
182
+ return true;
183
+ }
184
+
92
185
  function currentUpstream(root) {
93
186
  const result = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: root, check: false });
94
187
  return result.status === 0 ? result.stdout.trim() : '';
@@ -118,9 +211,7 @@ function normalizeTargetRef(root, target) {
118
211
  return value;
119
212
  }
120
213
 
121
- function defaultStartBase(root) {
122
- const upstream = currentUpstream(root);
123
- if (upstream && refExists(root, upstream)) return upstream;
214
+ function defaultMainlineBase(root) {
124
215
  const head = remoteHead(root);
125
216
  if (head && refExists(root, head)) return head;
126
217
  for (const candidate of ['origin/master', 'origin/main']) {
@@ -129,6 +220,23 @@ function defaultStartBase(root) {
129
220
  return 'HEAD';
130
221
  }
131
222
 
223
+ function defaultStartBase(root) {
224
+ const upstream = currentUpstream(root);
225
+ const mainline = defaultMainlineBase(root);
226
+ // Always refresh before cutting a worktree: a stale origin/master base sent
227
+ // an engine off to rebuild work that had already landed (2026-07-09, packs
228
+ // one-click flight rebuilt the stars backend from a base two PRs behind).
229
+ refreshRemoteRef(root, mainline);
230
+ if (!upstream || !refExists(root, upstream)) return mainline;
231
+ if (mainline === 'HEAD' || mainline === upstream) return upstream;
232
+ // A launcher upstream that is behind the mainline is a stale cut point
233
+ // (2026-07-05: a stale origin/task branch sent two flights into rebase
234
+ // conflicts). Start from the mainline unless the upstream carries new work.
235
+ refreshRemoteRef(root, upstream);
236
+ const behind = runGit(['merge-base', '--is-ancestor', upstream, mainline], { cwd: root, check: false });
237
+ return behind.status === 0 ? mainline : upstream;
238
+ }
239
+
132
240
  function defaultShipTarget(root) {
133
241
  const branch = currentBranch(root);
134
242
  if (branch) {
@@ -153,15 +261,21 @@ function baseBranchName(ref) {
153
261
  return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
154
262
  }
155
263
 
156
- function statusCounts(root) {
264
+ function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntrackedFiles = new Set() } = {}) {
157
265
  if (!fs.existsSync(root)) return null;
158
- const result = runGit(['status', '--porcelain'], { cwd: root, check: false });
266
+ // -uall expands untracked directories to individual files so ignoredUntrackedFiles
267
+ // can match exact paths (plain porcelain collapses them to "?? dir/").
268
+ const statusArgs = ignoredUntrackedFiles.size ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
269
+ const result = runGit(statusArgs, { cwd: root, check: false });
159
270
  if (result.status !== 0) return null;
160
271
  let staged = 0;
161
272
  let unstaged = 0;
162
273
  let untracked = 0;
163
274
  for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
275
+ const file = line.slice(3);
276
+ if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
164
277
  if (line.startsWith('??')) {
278
+ if (ignoredUntrackedFiles.has(file)) continue;
165
279
  untracked += 1;
166
280
  continue;
167
281
  }
@@ -171,6 +285,25 @@ function statusCounts(root) {
171
285
  return { staged, unstaged, untracked };
172
286
  }
173
287
 
288
+ function changedFiles(root, args) {
289
+ const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
290
+ if (result.status !== 0) return new Set();
291
+ return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
292
+ }
293
+
294
+ function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {}) {
295
+ const unstaged = changedFiles(root, ['diff', '--name-only']);
296
+ const staged = changedFiles(root, ['diff', '--cached', '--name-only']);
297
+ const commitMessage = String(message || '');
298
+ const skipped = REGEN_ADAPTER_FILES.filter(
299
+ (file) => unstaged.has(file) && !staged.has(file) && !commitMessage.includes(file)
300
+ );
301
+ if (!skipped.length) return [];
302
+ console.log(`ship: skipped regenerated adapter churn: ${skipped.join(', ')}`);
303
+ if (!dryRun) runGit(['checkout', '--', ...skipped], { cwd: root });
304
+ return skipped;
305
+ }
306
+
174
307
  function readFlag(args, name, fallback = '') {
175
308
  const prefix = `${name}=`;
176
309
  for (let i = 0; i < args.length; i += 1) {
@@ -215,17 +348,34 @@ function printStatus() {
215
348
  // Programmatic core shared by `atris worktree start` and `atris mission start
216
349
  // --worktree`: creates the branch + isolated checkout + identity sidecar and
217
350
  // returns the facts. Throws on failure; callers own messaging and next steps.
351
+ //
352
+ // checkoutBase (what the branch is cut from) and shipBase (what `atris
353
+ // worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
354
+ // separate on purpose. checkoutBase defaults to the launcher's own
355
+ // upstream/default remote base, so the agent starts from current work.
356
+ // shipBase defaults to origin/master for agent/member worktrees regardless of
357
+ // what branch the launcher happened to have checked out. recording the
358
+ // launcher's own feature branch as the ship target false-landed two PRs on
359
+ // 2026-07-04 (ship merged into the launcher's branch, never reached master).
360
+ // --base/--target still overrides both when the caller wants a different
361
+ // checkout point and ship target on purpose.
218
362
  function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
219
363
  const owner = member || agent;
220
364
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
221
365
  const branch = branchOverride || branchName(owner, task, now);
222
366
  const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
223
- const base = normalizeTargetRef(root, baseOverride || defaultStartBase(root));
367
+ const explicitBase = Boolean(baseOverride);
368
+ // Agent/member worktrees always cut from the mainline: honoring the
369
+ // launcher checkout's upstream let a dispatch inherit another session's
370
+ // in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
371
+ // unpushed commits from a parallel session and could not merge).
372
+ const checkoutBase = normalizeTargetRef(root, baseOverride || defaultMainlineBase(root));
373
+ const shipBase = explicitBase ? checkoutBase : normalizeTargetRef(root, 'origin/master');
224
374
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
225
375
  fs.mkdirSync(path.dirname(target), { recursive: true });
226
- refreshRemoteRef(root, base);
227
- runGit(['worktree', 'add', '-b', branch, target, base], { cwd: root });
228
- runGit(['config', `branch.${branch}.atris-base`, base], { cwd: target, check: false });
376
+ refreshRemoteRef(root, checkoutBase);
377
+ runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root });
378
+ runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target, check: false });
229
379
  runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target, check: false });
230
380
  runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target, check: false });
231
381
  fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
@@ -237,13 +387,14 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
237
387
  owner,
238
388
  task,
239
389
  branch,
240
- base,
390
+ base: shipBase,
391
+ checkout_base: checkoutBase,
241
392
  workspace_root: root,
242
393
  created_at: now.toISOString(),
243
394
  }, null, 2) + '\n',
244
395
  'utf8'
245
396
  );
246
- return { path: target, branch, base, owner };
397
+ return { path: target, branch, base: shipBase, checkoutBase, owner };
247
398
  }
248
399
 
249
400
  function startWorktree(args) {
@@ -298,12 +449,21 @@ function startWorktree(args) {
298
449
 
299
450
  function createOrFindPr(root, branch, targetRef, title, dryRun) {
300
451
  const targetBranch = baseBranchName(targetRef);
301
- const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url', '--jq', '"\\(.number) \\(.url)"'], {
452
+ // `gh pr view` (no --state filter) returns ANY PR for this branch name,
453
+ // including one already MERGED or CLOSED. A worktree branch that ships
454
+ // more than once (this same branch, more commits, another `worktree ship`)
455
+ // would then reuse the dead PR number: `gh pr merge <dead pr> --merge`
456
+ // exits 0 with just a warning (never fails), so the new commits silently
457
+ // never land while the ship command still reports success. Only reuse a
458
+ // PR that is still OPEN; anything else gets a fresh one.
459
+ const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
302
460
  cwd: root,
303
461
  encoding: 'utf8',
304
462
  });
305
463
  if (existing.status === 0 && existing.stdout.trim()) {
306
- return existing.stdout.trim();
464
+ const parts = existing.stdout.trim().split(/\s+/);
465
+ const state = parts.pop();
466
+ if (state === 'OPEN') return parts.join(' ');
307
467
  }
308
468
  const body = [
309
469
  'Automated Atris worktree ship.',
@@ -329,19 +489,41 @@ function prMergeRef(prOutput) {
329
489
  return text.split(/\s+/)[0];
330
490
  }
331
491
 
492
+ function shipHelp() {
493
+ console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
494
+ console.log('');
495
+ console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
496
+ console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
497
+ console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
498
+ console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
499
+ }
500
+
332
501
  function shipWorktree(args) {
502
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
503
+ shipHelp();
504
+ return 0;
505
+ }
333
506
  const root = repoRoot();
334
507
  const dryRun = hasFlag(args, '--dry-run');
335
508
  const noPr = hasFlag(args, '--no-pr');
509
+ const localMode = hasFlag(args, '--local') || hasFlag(args, '--local-merge');
336
510
  const merge = hasFlag(args, '--merge');
337
511
  const message = readFlag(args, '--message') || readFlag(args, '-m');
338
512
  const verify = readFlag(args, '--verify');
339
- const branch = currentBranch(root);
513
+ let branch = currentBranch(root);
340
514
  const worktrees = listWorktrees(root);
341
515
  const primary = worktrees[0]?.path;
342
516
  const targetRef = normalizeTargetRef(root, readFlag(args, '--target') || defaultShipTarget(root));
343
517
 
344
- if (!branch || branch === 'master' || branch === 'main') {
518
+ if (!branch && path.resolve(root) === path.resolve(primary)) {
519
+ console.error('blocked: detached head on the primary checkout');
520
+ return 2;
521
+ }
522
+ if (!branch) {
523
+ branch = detachedShipBranch(root);
524
+ if (!createDetachedShipBranch(root, branch)) return 2;
525
+ }
526
+ if (branch === 'master' || branch === 'main') {
345
527
  console.error('blocked: ship from a feature worktree branch, not master/main');
346
528
  return 2;
347
529
  }
@@ -350,8 +532,27 @@ function shipWorktree(args) {
350
532
  return 2;
351
533
  }
352
534
 
535
+ let oneLapProof = null;
536
+ try {
537
+ oneLapProof = readOneLapProofBinding(root, branch);
538
+ } catch (error) {
539
+ console.error(`blocked: ${error.message}`);
540
+ return 3;
541
+ }
542
+ const proofTreeMatches = (source) => {
543
+ try {
544
+ assertOneLapProofTree(root, oneLapProof, source);
545
+ return true;
546
+ } catch (error) {
547
+ console.error(`blocked: ${error.message}`);
548
+ return false;
549
+ }
550
+ };
551
+
353
552
  refreshRemoteRef(root, targetRef);
354
- const counts = statusCounts(root) || { staged: 0, unstaged: 0, untracked: 0 };
553
+ const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
554
+ const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
555
+ const counts = statusCounts(root, { ignoredUnstagedFiles }) || { staged: 0, unstaged: 0, untracked: 0 };
355
556
  const dirty = counts.staged || counts.unstaged || counts.untracked;
356
557
  if (dirty && !message) {
357
558
  console.error('blocked: --message is required when there are local changes to commit');
@@ -362,23 +563,33 @@ function shipWorktree(args) {
362
563
  console.log(`commit: ${message}`);
363
564
  if (!dryRun) {
364
565
  runGit(['add', '-A'], { cwd: root });
566
+ if (!proofTreeMatches('index')) return 3;
365
567
  runGit(['commit', '-m', message], { cwd: root });
568
+ if (!proofTreeMatches('head')) return 3;
366
569
  }
367
570
  } else {
368
571
  console.log('commit: skipped (no local changes)');
572
+ if (!dryRun && !proofTreeMatches('head')) return 3;
369
573
  }
370
574
 
371
575
  if (verify) {
576
+ const beforeVerify = dryRun ? [] : statusSnapshot(root);
372
577
  console.log(`verify: ${verify}`);
373
578
  if (!dryRun) runCommand(verify, { cwd: root });
579
+ const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
580
+ const verifyDelta = newStatusRows(beforeVerify, afterVerifyRows);
374
581
  const afterVerify = dryRun ? { staged: 0, unstaged: 0, untracked: 0 } : statusCounts(root) || { staged: 0, unstaged: 0, untracked: 0 };
375
582
  if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
376
583
  console.error(
377
584
  `blocked: verifier left checkout dirty staged=${afterVerify.staged} ` +
378
585
  `unstaged=${afterVerify.unstaged} untracked=${afterVerify.untracked}`
379
586
  );
587
+ if (verifyDelta.length) {
588
+ console.error(`new after verify: ${verifyDelta.slice(0, 8).join(', ')}`);
589
+ }
380
590
  return 3;
381
591
  }
592
+ if (!dryRun && !proofTreeMatches('head')) return 3;
382
593
  } else {
383
594
  console.log('verify: skipped (no --verify)');
384
595
  }
@@ -393,6 +604,35 @@ function shipWorktree(args) {
393
604
  }
394
605
  console.log(`merge_check: ${targetRef} clean`);
395
606
 
607
+ if (localMode) {
608
+ console.log('push: skipped (local mode)');
609
+ if (merge) {
610
+ console.log('merge: requested (local mode)');
611
+ if (!dryRun) {
612
+ const targetBranch = baseBranchName(targetRef);
613
+ const primaryBranch = currentBranch(primary);
614
+ if (primaryBranch !== targetBranch) {
615
+ const switched = runGit(['switch', targetBranch], { cwd: primary, check: false });
616
+ if (switched.status !== 0) {
617
+ throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
618
+ }
619
+ }
620
+ const primaryCounts = statusCounts(primary) || { staged: 0, unstaged: 0, untracked: 0 };
621
+ if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
622
+ throw new Error(
623
+ `primary checkout dirty staged=${primaryCounts.staged} ` +
624
+ `unstaged=${primaryCounts.unstaged} untracked=${primaryCounts.untracked}`
625
+ );
626
+ }
627
+ runGit(['merge', '--no-ff', branch, '-m', `Merge ${branch}`], { cwd: primary });
628
+ console.log('merge: merged (local mode)');
629
+ }
630
+ }
631
+ console.log('pr: skipped (local mode)');
632
+ console.log('done: worktree shipped');
633
+ return 0;
634
+ }
635
+
396
636
  console.log(`push: origin ${branch}`);
397
637
  if (!dryRun) runGit(['push', '-u', 'origin', branch], { cwd: root });
398
638
 
@@ -409,6 +649,11 @@ function shipWorktree(args) {
409
649
  mergeArgs.push('--merge');
410
650
  const merged = spawnSync('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
411
651
  if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
652
+ // `gh pr merge` on an already-merged PR exits 0 with a warning
653
+ // instead of failing, which would otherwise report a false landing.
654
+ if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
655
+ throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
656
+ }
412
657
  console.log('merge: merged');
413
658
  const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
414
659
  if (deleted.status === 0) {
@@ -423,6 +668,12 @@ function shipWorktree(args) {
423
668
  console.log('pr: skipped (--no-pr)');
424
669
  }
425
670
 
671
+ try {
672
+ stampLatestOpenBriefForWorktree(root, {
673
+ result: 'pass',
674
+ note: `worktree ship completed into ${targetRef}`,
675
+ }, { worktree: root });
676
+ } catch {}
426
677
  console.log('done: worktree shipped');
427
678
  return 0;
428
679
  }
@@ -436,7 +687,8 @@ function guard(args) {
436
687
  console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
437
688
  return 2;
438
689
  }
439
- const counts = statusCounts(root);
690
+ // The CLI's own worktree metadata (written by `worktree start`) must not count as dirt.
691
+ const counts = statusCounts(root, { ignoredUntrackedFiles: new Set(['.atris/agent-worktree.json']) });
440
692
  if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
441
693
  console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
442
694
  return 3;
@@ -456,6 +708,19 @@ function prune(args) {
456
708
 
457
709
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
458
710
 
711
+ // A worktree fresh off `worktree start` is merged into base by definition
712
+ // (zero commits yet), so without a grace window the janitor reaps it while
713
+ // the engine that requested it is still booting inside.
714
+ const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
715
+
716
+ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
717
+ try {
718
+ return now - fs.statSync(worktreePath).mtimeMs < WORKTREE_REAP_GRACE_MS;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+
459
724
  function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false } = {}) {
460
725
  const worktrees = listWorktrees(root);
461
726
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
@@ -503,6 +768,10 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
503
768
  kept.push({ ...item, reason: 'unmerged' });
504
769
  continue;
505
770
  }
771
+ if (worktreeWithinReapGrace(wtPath)) {
772
+ kept.push({ ...item, reason: 'fresh_worktree_grace' });
773
+ continue;
774
+ }
506
775
  const candidate = { ...item, reason: 'merged_into_base' };
507
776
  candidates.push(candidate);
508
777
  if (!apply) continue;
@@ -560,7 +829,9 @@ function guide() {
560
829
  console.log(' atris mission tick <id> --verify --complete-on-pass');
561
830
  console.log('');
562
831
  console.log('4. Ship only from the isolated worktree:');
563
- console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge');
832
+ console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge [--target <ref>]');
833
+ console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
834
+ console.log(' recommended verify: npm run test:fast && node --test <focused files>');
564
835
  console.log('');
565
836
  console.log('5. Clean merged worktrees:');
566
837
  console.log(' atris worktree cleanup');
@@ -576,7 +847,10 @@ function help() {
576
847
  console.log('');
577
848
  console.log(' atris worktree guide');
578
849
  console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim]');
579
- console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge]');
850
+ console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
851
+ console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
852
+ console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
853
+ console.log(' recommended verify: npm run test:fast && node --test <focused files>');
580
854
  console.log(' atris worktree status');
581
855
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
582
856
  console.log(' atris worktree prune [--apply]');
@@ -606,14 +880,19 @@ function worktreeCommand(args = []) {
606
880
 
607
881
  module.exports = {
608
882
  branchName,
883
+ statusSnapshot,
884
+ newStatusRows,
609
885
  createAgentWorktree,
886
+ createOrFindPr,
610
887
  cleanupWorktrees,
611
888
  defaultShipTarget,
612
889
  defaultStartBase,
613
890
  defaultWorktreePath,
614
891
  listWorktrees,
615
892
  parseWorktrees,
893
+ REGEN_ADAPTER_FILES,
616
894
  normalizeTargetRef,
895
+ restoreRegeneratedAdapterChurn,
617
896
  prMergeRef,
618
897
  slugify,
619
898
  statusCounts,