atris 3.74.5 → 3.74.6
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/commands/gm.js +1 -1
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +10 -3
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- 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/task-db.js +8 -4
- package/lib/yes-lock.js +22 -3
- package/lib/zen-apply.js +1 -0
- package/lib/zen.js +15 -0
- package/package.json +1 -1
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/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
|
@@ -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) {
|
|
@@ -141,7 +148,7 @@ function printApply(result, out) {
|
|
|
141
148
|
line(' almost zen. still out of place:');
|
|
142
149
|
for (const text of result.remaining) line(dim(` - ${text}`));
|
|
143
150
|
}
|
|
144
|
-
line(dim(` ${teamLine(result.team_files)}`));
|
|
151
|
+
line(dim(` ${teamLine(result.team_files, result.team_places)}`));
|
|
145
152
|
if (result.saved.length) {
|
|
146
153
|
line(dim(' nothing was thrown away. see what zen saved: git for-each-ref refs/archive refs/heads/zen-backup'));
|
|
147
154
|
}
|
|
@@ -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 };
|