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 +9 -0
- package/commands/gm.js +1 -1
- package/commands/idea.js +36 -0
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +14 -5
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- package/lib/engine-login.js +105 -0
- package/lib/fleet.js +4 -1
- package/lib/gm-bet.js +405 -17
- package/lib/gm-card.js +431 -0
- package/lib/gm-phone-page.js +481 -1004
- package/lib/gm-phone.js +33 -38
- package/lib/idea-tick.js +20 -0
- package/lib/task-db.js +8 -4
- package/lib/values.js +38 -0
- package/lib/yes-lock.js +22 -3
- package/lib/zen-apply.js +17 -1
- package/lib/zen.js +90 -14
- package/package.json +1 -1
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>] [--
|
|
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
|
-
|
|
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
|
|
26
|
-
// it, so
|
|
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
|
|
29
|
-
//
|
|
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
|
-
|
|
1051
|
-
|
|
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
|
|
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
|
|
1082
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'),
|