atris 3.74.5 → 3.74.7

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/bin/atris.js CHANGED
@@ -1967,6 +1967,15 @@ function showWelcomeVisualization() {
1967
1967
  // Silent: the lineup is a hint, not a gate.
1968
1968
  }
1969
1969
 
1970
+ // The four values settle close calls, so every new chat sees them, not
1971
+ // only an agent that reads atris.md to the end (lib/values.js).
1972
+ try {
1973
+ const values = require('../lib/values').valuesForRoot(cwd);
1974
+ values.forEach((value, index) => console.log(row(index ? '' : 'values', `${value.name.padEnd(14)}${value.gist}`)));
1975
+ } catch {
1976
+ // Advisory only; never blocks boot.
1977
+ }
1978
+
1970
1979
  const hasNoTasks = glance.active === 0
1971
1980
  && glance.review === 0
1972
1981
  && glance.reviewCertified === 0;
package/commands/gm.js CHANGED
@@ -31,7 +31,7 @@ function showHelp() {
31
31
  console.log(' atris gm card <member> [--json] one member\'s profile page');
32
32
  console.log(' atris gm target [--landed N] [--max-abandoned N] [--max-stuck N] [--clear]');
33
33
  console.log(' atris gm phone [--port 7777] [--send] [--new-link] decision cards on your phone');
34
- console.log(' atris gm walk [--real-copy <workspace>] [--threshold 0.8] [--no-browser] check every stage of the phone orb');
34
+ console.log(' atris gm walk [--real-copy <workspace>] [--votes 3] [--no-browser] check every stage of the phone orb');
35
35
  console.log(' atris gm --json machine-readable state');
36
36
  console.log(' atris gm --watch [--interval 10]');
37
37
  console.log('');
package/commands/idea.js CHANGED
@@ -31,6 +31,7 @@ function ideaUsage() {
31
31
  ' atris idea retry <n> [--repo <path>] [--verify "<cmd>"]',
32
32
  ' atris idea tick [--repo <path>] [--max-minutes <n>]',
33
33
  ' atris idea on|off [--repo <path>] [--dry-run]',
34
+ ' atris idea login|logout (the Claude login sealed builds use)',
34
35
  ].join('\n');
35
36
  }
36
37
 
@@ -213,6 +214,35 @@ function retryIdea(ref, { repo, verify = '', json, log }) {
213
214
  return 0;
214
215
  }
215
216
 
217
+ // One command for the login a sealed build needs: Claude's own setup-token
218
+ // runs on the person's terminal, the token is picked off the screen (or
219
+ // pasted when it cannot be), proven with a sign-in from a blank home, then
220
+ // saved where only the owner can read it. Nothing is saved when it fails.
221
+ function ideaLogin({ log = console.log, deps = {} } = {}) {
222
+ const login = { ...require('../lib/engine-login'), ...deps };
223
+ const tty = deps.isTTY !== undefined ? deps.isTTY : Boolean(process.stdin.isTTY && process.stdout.isTTY);
224
+ if (!tty) {
225
+ log('run this in your own terminal: atris idea login');
226
+ return 2;
227
+ }
228
+ log('a browser window opens for Claude. approve it there; there is nothing to copy.');
229
+ let token = login.findToken(login.recordSetupToken().text);
230
+ if (!token && login.askToken) token = String(login.askToken() || '').trim();
231
+ if (!token) {
232
+ log('no login came back from claude setup-token. nothing saved.');
233
+ return 1;
234
+ }
235
+ const check = login.checkToken(token);
236
+ if (!check.ok) {
237
+ log(`that login did not sign in${check.detail ? `: ${check.detail}` : ''}. nothing saved.`);
238
+ return 1;
239
+ }
240
+ login.saveToken(token);
241
+ log('done. sealed builds now sign in to Claude on your subscription.');
242
+ log('undo: atris idea logout');
243
+ return 0;
244
+ }
245
+
216
246
  function ideaAtris(args = process.argv.slice(3), { cwd = process.cwd(), log = console.log, keepDeps = {} } = {}) {
217
247
  const list = Array.isArray(args) ? args : [];
218
248
  if (argsWantHelp(list)) {
@@ -224,6 +254,12 @@ function ideaAtris(args = process.argv.slice(3), { cwd = process.cwd(), log = co
224
254
  const json = flags.json === true;
225
255
  const verb = words[0];
226
256
 
257
+ if (verb === 'login' && words.length === 1) return ideaLogin({ log, deps: keepDeps.login });
258
+ if (verb === 'logout' && words.length === 1) {
259
+ const { forgetToken } = require('../lib/engine-login');
260
+ log(forgetToken() ? 'sealed builds no longer have a Claude login.' : 'there was no saved login.');
261
+ return 0;
262
+ }
227
263
  if (verb === 'retry' && words.length <= 2) return retryIdea(words[1], { repo, verify: flags.verify || '', json, log });
228
264
  if ((verb === 'on' || verb === 'off') && words.length === 1) {
229
265
  return switchTimer(verb === 'on', { repo, dryRun: flags.dryRun === true, json, log, keepDeps });
package/commands/task.js CHANGED
@@ -393,7 +393,8 @@ function refreshCareerXpProjection(workspaceRoot) {
393
393
  if (!workspaceRoot) return null;
394
394
  try {
395
395
  const { collectLocalXpProjection } = require('../commands/xp');
396
- return collectLocalXpProjection(['--workspace', workspaceRoot]);
396
+ const { careerXpRoot } = require('../lib/career-xp-root');
397
+ return collectLocalXpProjection(['--workspace', careerXpRoot(workspaceRoot)]);
397
398
  } catch (error) {
398
399
  return {
399
400
  ok: false,
@@ -5486,7 +5487,7 @@ function cmdAcceptGroup(args) {
5486
5487
  proof,
5487
5488
  });
5488
5489
  if (!done.updated) { accepted.push({ id: task.id, ok: false, reason: 'not_review' }); continue; }
5489
- taskDb.reviewTask(db, {
5490
+ const reviewed = taskDb.reviewTask(db, {
5490
5491
  id: task.id,
5491
5492
  actor,
5492
5493
  reward: 1,
@@ -5495,6 +5496,7 @@ function cmdAcceptGroup(args) {
5495
5496
  proof,
5496
5497
  careerXpEligible: isVerified, // honest: career XP ONLY for rows the human actually verified
5497
5498
  });
5499
+ refreshCareerXpAfterReview(reviewed);
5498
5500
  stampAcceptGroupMetadata(taskDb, db, task.id, { actor, group: groupLabel, verified: isVerified, sampleVerifiedIds: [...verifiedIds] });
5499
5501
  accepted.push({ id: task.id, ok: true, verified: isVerified, career_xp: isVerified });
5500
5502
  }
package/commands/xp.js CHANGED
@@ -7,6 +7,9 @@ const crypto = require('crypto');
7
7
  const { spawnSync } = require('child_process');
8
8
  const { hasFlag: hasExactFlag } = require('../lib/arg-parser');
9
9
  const { treeHashFor } = require('../lib/tree-hash');
10
+ const { careerXpRoot, careerXpLinkedRoots } = require('../lib/career-xp-root');
11
+ const { withCareerXpLock } = require('../lib/career-xp-lock');
12
+ const { normalizeActor, taskBuilder } = require('../lib/review-integrity');
10
13
 
11
14
  const DEFAULT_GRAPH_DAYS = 365;
12
15
  const MAX_SYNC_GRAPH_DAYS = 370;
@@ -22,15 +25,15 @@ const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json
22
25
  const CODEX_STATE_FILE = path.join(os.homedir(), '.codex', 'state_5.sqlite');
23
26
 
24
27
  // Default single-workspace XP root. Local XP reads task_episodes.jsonl (written
25
- // by task-db at the resolved workspace root) and writes its projection beside
26
- // it, so the `--workspace` default must resolve the same shared root, otherwise
28
+ // by task-db at the main checkout's workspace root) and writes its projection
29
+ // beside it, so reads must resolve the same shared root, otherwise
27
30
  // `xp --local` from a subdir (e.g. backend/) read the wrong/empty episodes and
28
- // split the projection into a nested .atris. Explicit --workspace still wins;
29
- // the multi-root roster path (discoverCareerXpWorkspaces) is untouched. Falls
31
+ // split the projection into a nested .atris. Explicit --workspace selects the
32
+ // repository and nested workspace, then also resolves its shared ledger. Falls
30
33
  // back to raw cwd if the resolver can't load.
31
34
  function defaultXpWorkspace() {
32
35
  try {
33
- return require('../lib/mission-root').resolveWorkspaceRoot(process.cwd());
36
+ return careerXpRoot(require('../lib/mission-root').resolveWorkspaceRoot(process.cwd()));
34
37
  } catch {
35
38
  return process.cwd();
36
39
  }
@@ -921,7 +924,7 @@ function receiptFromTaskEpisode(episode, treeHash) {
921
924
  );
922
925
  const proof = String(episode?.proof || '').trim();
923
926
  const reward = asNumber(episode?.career_xp?.reward ?? episode?.reward?.value, 0);
924
- if (!episodeId || (!rejected && (!eligible || !proof || reward <= 0))) return null;
927
+ if (!episodeId || (!rejected && (!eligible || !proof || reward <= 0 || !hasIndependentXpReview(episode)))) return null;
925
928
 
926
929
  return {
927
930
  schema: 'atris.career_xp_receipt.v1',
@@ -1047,8 +1050,55 @@ function buildCareerXpProjection(receipts, workspace, integrity = {}) {
1047
1050
  };
1048
1051
  }
1049
1052
 
1050
- function collectLocalXpProjectionState(args = [], { write = true } = {}) {
1051
- const workspace = path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace()));
1053
+ // The eligible flag records acceptance intent, not reviewer independence.
1054
+ // Apply the same actor evidence to new episodes and historical recovery.
1055
+ function hasIndependentXpReview(episode) {
1056
+ const builder = taskBuilder(episode?.state);
1057
+ if (!builder) return false;
1058
+ const reviewer = normalizeActor(episode.action?.actor);
1059
+ if (reviewer && reviewer !== builder) return true;
1060
+ const metadata = episode.state.metadata || {};
1061
+ const verifier = normalizeActor(metadata.certified_verified_by);
1062
+ const reviewActors = Array.isArray(metadata.agent_review_actors) ? metadata.agent_review_actors.map(normalizeActor) : [];
1063
+ return metadata.agent_certified === true && metadata.machine_verified === true
1064
+ && Boolean(verifier) && verifier !== builder
1065
+ && verifier === normalizeActor(metadata.independent_review_by)
1066
+ && reviewActors.includes(verifier);
1067
+ }
1068
+
1069
+ function readLinkedXpFile(filePath, options = {}) {
1070
+ try {
1071
+ return readJsonl(filePath, options);
1072
+ } catch {
1073
+ console.error(`warning: skipped unreadable linked xp file at ${filePath}`);
1074
+ return [];
1075
+ }
1076
+ }
1077
+
1078
+ // Historical linked ledgers predate the shared writer. Require explicit
1079
+ // eligibility here; receipt conversion also enforces independent review.
1080
+ function recoverLinkedTaskEpisodes(workspace) {
1081
+ return careerXpLinkedRoots(workspace).flatMap(root => readLinkedXpFile(path.join(root, TASK_EPISODES_FILE), { allowPartialTail: true }))
1082
+ .filter(episode => episode?.state?.status === 'done' && episode?.career_xp?.eligible === true
1083
+ && String(episode.proof || '').trim()
1084
+ && asNumber(episode.career_xp.reward ?? episode.reward?.value) > 0
1085
+ && hasIndependentXpReview(episode));
1086
+ }
1087
+
1088
+ function recoverLinkedReceipts(workspace) {
1089
+ return careerXpLinkedRoots(workspace).flatMap(root => {
1090
+ const chain = normalizeReceiptChain(readLinkedXpFile(path.join(root, CAREER_XP_RECEIPTS_FILE)), { allowLegacyUpgrade: false });
1091
+ return chain.integrity.status === 'verified' ? chain.receipts : [];
1092
+ });
1093
+ }
1094
+
1095
+ function collectLocalXpProjectionState(args = [], { write = true, resolveSharedRoot = true } = {}) {
1096
+ const selected = path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace()));
1097
+ const workspace = resolveSharedRoot ? careerXpRoot(selected) : selected;
1098
+ return withCareerXpLock(workspace, () => collectLockedXpProjectionState(workspace, { write, resolveSharedRoot }));
1099
+ }
1100
+
1101
+ function collectLockedXpProjectionState(workspace, { write, resolveSharedRoot }) {
1052
1102
  const episodePath = path.join(workspace, TASK_EPISODES_FILE);
1053
1103
  const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
1054
1104
  const projectionPath = path.join(workspace, CAREER_XP_PROJECTION_FILE);
@@ -1075,12 +1125,13 @@ function collectLocalXpProjectionState(args = [], { write = true } = {}) {
1075
1125
  && fileSize(episodePath) > 0
1076
1126
  && fileSize(receiptsPath) === 0;
1077
1127
  const tail = readTaskEpisodeTail(episodePath, cursorPath, { forceReset: replayFromStart });
1078
- const treeHash = write && tail.episodes.length ? treeHashFor(workspace) : null;
1128
+ const recovered = resolveSharedRoot ? recoverLinkedTaskEpisodes(workspace) : [];
1129
+ const episodes = [...tail.episodes, ...recovered];
1130
+ const treeHash = write && episodes.length ? treeHashFor(workspace) : null;
1079
1131
  const seen = new Set(existingChain.receipts.map(receipt => receipt.receipt_id).filter(Boolean));
1080
1132
  let previousHash = existingChain.integrity.head_hash || null;
1081
- const newReceipts = tail.episodes
1082
- .map((episode) => receiptFromTaskEpisode(episode, treeHash))
1083
- .filter(Boolean)
1133
+ const recoveredReceipts = resolveSharedRoot ? recoverLinkedReceipts(workspace) : [];
1134
+ const newReceipts = [...recoveredReceipts, ...episodes.map(episode => receiptFromTaskEpisode(episode, treeHash)).filter(Boolean)]
1084
1135
  .filter((receipt) => {
1085
1136
  if (seen.has(receipt.receipt_id)) return false;
1086
1137
  seen.add(receipt.receipt_id);
@@ -1381,12 +1432,14 @@ function collectAllLocalXpProjection(args = []) {
1381
1432
  const searchRoots = defaultAllSearchRoots(args).filter(root => fs.existsSync(root));
1382
1433
  const explicitWorkspaces = readFlagValues(args, '--workspace');
1383
1434
  const discovered = searchRoots.flatMap(root => discoverCareerXpWorkspaces(root));
1384
- const workspaces = uniquePaths([...explicitWorkspaces, ...discovered]);
1435
+ // Local recovery folds old checkout episodes into the durable main ledger.
1436
+ // Canonicalize before aggregation so copies can never earn twice.
1437
+ const workspaces = uniquePaths([...explicitWorkspaces, ...discovered].map(careerXpRoot));
1385
1438
  const projections = workspaces.map((workspace) => {
1386
1439
  try {
1387
1440
  return {
1388
1441
  workspace_root: workspace,
1389
- projection: collectLocalXpProjection(['--workspace', workspace]),
1442
+ projection: collectLocalXpProjectionState(['--workspace', workspace]).projection,
1390
1443
  };
1391
1444
  } catch (error) {
1392
1445
  return {
@@ -1641,7 +1694,7 @@ LIMIT 25
1641
1694
  }
1642
1695
 
1643
1696
  function buildCareerXpSessionCapsule(args = []) {
1644
- const workspace = path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace()));
1697
+ const workspace = careerXpRoot(path.resolve(readInlineFirstFlag(args, '--workspace', defaultXpWorkspace())));
1645
1698
  const sinceInput = readInlineFirstFlag(args, '--since', 'today');
1646
1699
  const untilInput = readInlineFirstFlag(args, '--until', null);
1647
1700
  const since = parseSessionBoundary(sinceInput, startOfToday());
package/commands/zen.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { abandonedDaysFromEnv, checkZen, sideCopyLines } = require('../lib/zen');
3
+ const { abandonedDaysFromEnv, checkZen, nextSaveIn, sideCopyLines } = require('../lib/zen');
4
4
  const { applyZen } = require('../lib/zen-apply');
5
5
  const { workspaceZen } = require('../lib/zen-workspace');
6
6
  const { fleetFile, fleetRepos, fleetZen, printFleet } = require('../lib/zen-fleet');
@@ -57,9 +57,16 @@ function makeOut(calm) {
57
57
  return { dim, line, rake };
58
58
  }
59
59
 
60
- function teamLine(n) {
60
+ // Name the three biggest places so a large count reads as "mostly agent
61
+ // notes in atris/" rather than an unexplained total.
62
+ function teamLine(n, places = []) {
61
63
  if (!n) return 'no unsaved team notes.';
62
- return n === 1 ? '1 unsaved team note left in place.' : `${n} unsaved team notes left in place.`;
64
+ const head = n === 1 ? '1 unsaved team note left in place' : `${n} unsaved team notes left in place`;
65
+ if (!places.length) return `${head}.`;
66
+ const named = places.slice(0, 3).map((p) => `${p.place} ${p.count}`);
67
+ const rest = places.length - named.length;
68
+ if (rest) named.push(`and ${rest} more ${rest === 1 ? 'place' : 'places'}`);
69
+ return `${head}: ${named.join(', ')}.`;
63
70
  }
64
71
 
65
72
  function printYours(yours, line, dim) {
@@ -90,7 +97,9 @@ function printCheck(result, workspace) {
90
97
  console.log(' run atris zen to make it zen');
91
98
  }
92
99
  if (result.worktrees_with_work.length) {
93
- for (const text of sideCopyLines(result.worktrees_with_work, abandonedDaysFromEnv())) console.log(` ${text}`);
100
+ const days = abandonedDaysFromEnv();
101
+ const wait = nextSaveIn(result.worktrees_with_work, days);
102
+ for (const text of sideCopyLines(result.worktrees_with_work, days, wait)) console.log(` ${text}`);
94
103
  }
95
104
  const held = result.worktrees_locked?.length || 0;
96
105
  if (held) {
@@ -141,7 +150,7 @@ function printApply(result, out) {
141
150
  line(' almost zen. still out of place:');
142
151
  for (const text of result.remaining) line(dim(` - ${text}`));
143
152
  }
144
- line(dim(` ${teamLine(result.team_files)}`));
153
+ line(dim(` ${teamLine(result.team_files, result.team_places)}`));
145
154
  if (result.saved.length) {
146
155
  line(dim(' nothing was thrown away. see what zen saved: git for-each-ref refs/archive refs/heads/zen-backup'));
147
156
  }
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { sleepSync } = require('./sleep-sync');
7
+
8
+ function alive(pid) {
9
+ if (!Number.isInteger(pid) || pid <= 0) return false;
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ } catch (error) {
14
+ return error.code === 'EPERM';
15
+ }
16
+ }
17
+
18
+ // Callers resolve the shared root before entering. Do not acquire recursively.
19
+ function withCareerXpLock(workspace, action, { timeoutMs = 10000 } = {}) {
20
+ const file = path.join(workspace, '.atris', 'state', 'career_xp.lock');
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ const holder = JSON.stringify({ pid: process.pid, host: os.hostname() });
23
+ const deadline = Date.now() + timeoutMs;
24
+ for (;;) {
25
+ let fd;
26
+ try {
27
+ fd = fs.openSync(file, 'wx');
28
+ } catch (error) {
29
+ if (error.code !== 'EEXIST') throw error;
30
+ }
31
+ if (fd !== undefined) {
32
+ try {
33
+ fs.writeFileSync(fd, holder);
34
+ return action();
35
+ } finally {
36
+ fs.closeSync(fd);
37
+ fs.unlinkSync(file);
38
+ }
39
+ }
40
+ try {
41
+ const before = fs.statSync(file);
42
+ let owner;
43
+ try { owner = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
44
+ // A newly created file may still be receiving its owner record.
45
+ const stale = owner ? owner.host === os.hostname() && !alive(owner.pid)
46
+ : Date.now() - before.mtimeMs > 30000;
47
+ if (stale) {
48
+ const after = fs.statSync(file);
49
+ if (before.ino === after.ino && before.mtimeMs === after.mtimeMs) fs.unlinkSync(file);
50
+ continue;
51
+ }
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT') throw error;
54
+ continue;
55
+ }
56
+ if (Date.now() >= deadline) throw new Error('xp ledger is busy; retry');
57
+ sleepSync(20);
58
+ }
59
+ }
60
+
61
+ module.exports = { withCareerXpLock };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ // Jobs keep their own checkout for verification. Their durable XP belongs in
8
+ // the main checkout, where removing a landed job's checkout cannot erase it.
9
+ // Preserve a nested workspace's relative path so customer workspaces stay apart.
10
+ function careerXpRoot(workspace) {
11
+ const root = path.resolve(workspace);
12
+ try {
13
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
14
+ cwd: root, encoding: 'utf8', timeout: 5000,
15
+ });
16
+ if (top.status !== 0 || !top.stdout.trim()) return root;
17
+ const listing = spawnSync('git', ['worktree', 'list', '--porcelain', '-z'], {
18
+ cwd: root, encoding: 'utf8', timeout: 5000,
19
+ });
20
+ const first = String(listing.stdout || '').split('\0')[0];
21
+ if (listing.status !== 0 || !first.startsWith('worktree ')) return root;
22
+ const main = first.slice('worktree '.length);
23
+ // Bare repositories and separate git storage may name the storage folder,
24
+ // not a checkout. Never create an XP ledger inside git's internal files.
25
+ if (!fs.existsSync(path.join(main, '.git'))) return root;
26
+ const relative = path.relative(top.stdout.trim(), root);
27
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return root;
28
+ return path.resolve(main, relative);
29
+ } catch {
30
+ return root;
31
+ }
32
+ }
33
+
34
+ // Enumerate only checkouts registered to this repository, at the same nested
35
+ // workspace offset. Never crawl unrelated repositories or customer workspaces.
36
+ function careerXpLinkedRoots(workspace) {
37
+ const root = path.resolve(workspace);
38
+ try {
39
+ // A nested workspace may exist only in a linked checkout so far.
40
+ let existing = root;
41
+ while (!fs.existsSync(existing) && path.dirname(existing) !== existing) existing = path.dirname(existing);
42
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], {
43
+ cwd: existing, encoding: 'utf8', timeout: 5000,
44
+ });
45
+ if (top.status !== 0 || !top.stdout.trim()) return [];
46
+ const relative = path.relative(top.stdout.trim(), root);
47
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return [];
48
+ const listing = spawnSync('git', ['worktree', 'list', '--porcelain', '-z'], {
49
+ cwd: existing, encoding: 'utf8', timeout: 5000,
50
+ });
51
+ if (listing.status !== 0) return [];
52
+ return String(listing.stdout || '').split('\0')
53
+ .filter(field => field.startsWith('worktree '))
54
+ .map(field => path.resolve(field.slice('worktree '.length), relative))
55
+ .filter(candidate => candidate !== root);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ module.exports = { careerXpRoot, careerXpLinkedRoots };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // The one Claude login a sealed build may use. A sealed build hides the real
4
+ // home, and the normal Claude login lives in the macOS keychain and refreshes
5
+ // itself, so a build cannot borrow it. `atris idea login` runs Claude's own
6
+ // `claude setup-token` (a long-lived login on the same subscription), keeps the
7
+ // token in a file only the owner can read, and the sealed build passes it in.
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+ const { atrisHome } = require('./state-home');
14
+
15
+ const TOKEN_PATTERN = /sk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/g;
16
+
17
+ function tokenFile(env = process.env) {
18
+ return path.join(atrisHome(env), 'secrets', 'claude-oauth-token');
19
+ }
20
+
21
+ // The token from a recording of `claude setup-token`, colors and cursor moves
22
+ // stripped. The last one wins, since the screen may redraw.
23
+ function findToken(text) {
24
+ const plain = String(text || '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b[\]P][^\x07\x1b]*(\x07|\x1b\\)/g, '');
25
+ const hits = plain.match(TOKEN_PATTERN);
26
+ return hits ? hits[hits.length - 1] : '';
27
+ }
28
+
29
+ function savedToken(env = process.env) {
30
+ try {
31
+ return fs.readFileSync(tokenFile(env), 'utf8').trim();
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ function saveToken(token, env = process.env) {
38
+ const file = tokenFile(env);
39
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
40
+ fs.chmodSync(path.dirname(file), 0o700);
41
+ fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
42
+ fs.chmodSync(file, 0o600);
43
+ return file;
44
+ }
45
+
46
+ function forgetToken(env = process.env) {
47
+ const file = tokenFile(env);
48
+ if (!fs.existsSync(file)) return false;
49
+ fs.rmSync(file, { force: true });
50
+ return true;
51
+ }
52
+
53
+ // Runs `claude setup-token` on the person's own terminal while `script`
54
+ // records what it prints, then reads the recording and deletes it.
55
+ function recordSetupToken({ spawn = spawnSync, platform = process.platform } = {}) {
56
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-login-'));
57
+ fs.chmodSync(dir, 0o700);
58
+ const log = path.join(dir, 'screen');
59
+ try {
60
+ const args = platform === 'darwin'
61
+ ? ['-q', log, 'claude', 'setup-token']
62
+ : ['-q', '-c', 'claude setup-token', log];
63
+ const result = spawn('script', args, { stdio: 'inherit' });
64
+ const text = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
65
+ return { status: result.status, text };
66
+ } finally {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ // When the token cannot be read off the screen, the person pastes it once,
72
+ // without it showing on screen.
73
+ function askToken({ spawn = spawnSync } = {}) {
74
+ const script = 'printf "paste the token claude printed (it stays hidden): " >&2; stty -echo; read -r t; stty echo; printf "\\n" >&2; printf %s "$t"';
75
+ const result = spawn('sh', ['-c', script], { stdio: ['inherit', 'pipe', 'inherit'], encoding: 'utf8' });
76
+ return String(result.stdout || '').trim();
77
+ }
78
+
79
+ // Signs in the way a sealed build does: a blank home, no keychain, only the
80
+ // token. Returns { ok, detail }.
81
+ function checkToken(token, { spawn = spawnSync } = {}) {
82
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-login-check-'));
83
+ try {
84
+ const result = spawn('claude', ['-p', 'Reply with the single word ok.', '--model', 'haiku',
85
+ '--settings', JSON.stringify({ disableAllHooks: true }), '--strict-mcp-config', '--tools', ''], {
86
+ cwd: home,
87
+ encoding: 'utf8',
88
+ timeout: 120000,
89
+ env: {
90
+ PATH: process.env.PATH || '',
91
+ HOME: home,
92
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
93
+ CLAUDE_CODE_OAUTH_TOKEN: token,
94
+ ANTHROPIC_API_KEY: '',
95
+ },
96
+ });
97
+ const out = `${result.stdout || ''}${result.stderr || ''}`.trim();
98
+ const ok = result.status === 0 && /\bok\b/i.test(result.stdout || '');
99
+ return { ok, detail: ok ? '' : (out.split('\n').pop() || `exit ${result.status}`).slice(0, 200) };
100
+ } finally {
101
+ fs.rmSync(home, { recursive: true, force: true });
102
+ }
103
+ }
104
+
105
+ module.exports = { findToken, savedToken, saveToken, forgetToken, recordSetupToken, askToken, checkToken };
package/lib/fleet.js CHANGED
@@ -1907,7 +1907,10 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
1907
1907
  // owner policy: claude work runs on the subscription login, never API
1908
1908
  // billing, so the api key is stripped even when the parent env has one.
1909
1909
  ANTHROPIC_API_KEY: '',
1910
- CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN'),
1910
+ // `atris idea login` saves this one login for sealed builds; a token
1911
+ // already in the environment wins.
1912
+ CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN')
1913
+ || (selectedEngine === 'claude' ? require('./engine-login').savedToken() : ''),
1911
1914
  CURSOR_API_KEY: selectedSecret(['cursor'], 'CURSOR_API_KEY'),
1912
1915
  DEVIN_API_KEY: selectedSecret(['devin'], 'DEVIN_API_KEY'),
1913
1916
  XAI_API_KEY: selectedSecret(['grok'], 'XAI_API_KEY'),