atris 3.74.4 → 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.
Files changed (124) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. package/utils/auth.js +1 -0
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
@@ -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) {
@@ -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
  }
@@ -20,15 +20,30 @@ function shellQuote(text) {
20
20
  return `'${String(text).replace(/'/g, `'\\''`)}'`;
21
21
  }
22
22
 
23
+ // The job a timer runs. Campaign wake is the default; `atris idea on` passes
24
+ // its own (idea tick every ten minutes).
25
+ const CAMPAIGN_WAKE_JOB = Object.freeze({
26
+ name: 'campaign-wake',
27
+ args: ['campaign', 'wake', '--json'],
28
+ every_seconds: 60,
29
+ marker: MARKER,
30
+ });
31
+
32
+ function cronSchedule(seconds) {
33
+ const minutes = Math.max(1, Math.round(Number(seconds || 60) / 60));
34
+ return minutes === 1 ? '* * * * *' : `*/${minutes} * * * *`;
35
+ }
36
+
23
37
  // One timer per checkout: the label and marker carry a short hash of its root.
24
- function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
38
+ function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '', job = CAMPAIGN_WAKE_JOB }) {
25
39
  const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
40
+ const every = Number(job.every_seconds) || 60;
26
41
  // node by name on the saved PATH, so upgrading node does not break the timer.
27
- const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
28
- const logPath = path.join(home, 'campaign-wake.log');
29
- const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
42
+ const command = ['/usr/bin/env', nodeBin, atrisBin, ...job.args];
43
+ const logPath = path.join(home, job.log || `${job.name}.log`);
44
+ const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: every };
30
45
  if (platform === 'darwin') {
31
- const label = `ai.atris.campaign-wake.${slot}`;
46
+ const label = `ai.atris.${job.name}.${slot}`;
32
47
  const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
33
48
  const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
49
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -40,7 +55,7 @@ function planKeep({ root, home, platform = process.platform, homeDir = os.homedi
40
55
  <key>WorkingDirectory</key><string>${xml(root)}</string>
41
56
  <key>EnvironmentVariables</key>
42
57
  <dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
43
- <key>StartInterval</key><integer>60</integer>
58
+ <key>StartInterval</key><integer>${every}</integer>
44
59
  <key>StandardOutPath</key><string>${xml(logPath)}</string>
45
60
  <key>StandardErrorPath</key><string>${xml(logPath)}</string>
46
61
  </dict>
@@ -48,8 +63,8 @@ function planKeep({ root, home, platform = process.platform, homeDir = os.homedi
48
63
  `;
49
64
  return { ...base, kind: 'launchd', label, file, contents };
50
65
  }
51
- const marker = `${MARKER}_${slot}`;
52
- const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
66
+ const marker = `${job.marker || MARKER}_${slot}`;
67
+ const line = `${cronSchedule(every)} cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
53
68
  return { ...base, kind: 'cron', marker, crontab_line: line };
54
69
  }
55
70
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const fs = require('fs');
8
9
  const path = require('path');
9
10
  const crypto = require('crypto');
10
11
  const { spawn } = require('child_process');
@@ -37,6 +38,18 @@ function proposalId(objective) {
37
38
  return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
38
39
  }
39
40
 
41
+ function briefPath(root, campaignId) {
42
+ return path.join(root, 'atris', 'campaigns', campaignId, 'BRIEF.md');
43
+ }
44
+
45
+ function readBrief(root, campaignId) {
46
+ try {
47
+ return fs.readFileSync(briefPath(root, campaignId), 'utf8').trim();
48
+ } catch {
49
+ return '';
50
+ }
51
+ }
52
+
40
53
  function rankCampaignMembers(view, rsiSummary = null, events = null) {
41
54
  const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
42
55
  const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
@@ -99,7 +112,7 @@ function completionOrderedMissions(view, events) {
99
112
 
100
113
  // What the owner's engine reads before proposing: the goal, how it is judged,
101
114
  // what has been tried, and how it went.
102
- function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null) {
115
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null, brief = '') {
103
116
  const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}${typeof m.reward === 'number' ? `, reward ${m.reward >= 0 ? '+' : ''}${m.reward}` : ''}`).join('\n') || '- none yet';
104
117
  const ranked = rankCampaignMembers(view, rsiSummary, events);
105
118
  const memberLine = ranked.map((r) => {
@@ -113,6 +126,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummar
113
126
  `You plan the next missions for an Atris campaign. Answer with JSON only.`,
114
127
  ``,
115
128
  `Campaign: ${view.title}`,
129
+ brief ? `Brief:\n${brief}` : '',
116
130
  `Judge (the campaign is done when this command exits 0): ${view.judge}`,
117
131
  `Last judge reading: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${typeof view.last_judge.reading === 'number' ? ` (${view.last_judge.reading} of 1)` : ''}: ${view.answer.text || ''}` : 'never run'}`,
118
132
  judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
@@ -231,7 +245,8 @@ function makeLoop(root, options = {}, deps = {}) {
231
245
  });
232
246
  rsiSummary = summarizeStoreRsi(store);
233
247
  } catch {}
234
- const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events);
248
+ const brief = (deps.brief ? deps.brief(id) : readBrief(root, id)).trim();
249
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events, brief);
235
250
  const answer = await ask(root, current, prompt);
236
251
  const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
237
252
  // Planning costs money too. It rides on the first mission it produced as a
@@ -242,8 +257,10 @@ function makeLoop(root, options = {}, deps = {}) {
242
257
  if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
243
258
  proposals.forEach((proposal, index) => {
244
259
  const missionId = proposalId(proposal.objective);
260
+ const key = claimKey(proposal.objective);
261
+ if (brief && !proposal.objective.includes(brief)) proposal.objective = `${proposal.objective}\n\n${brief}`;
245
262
  append(id, 'mission_proposed', { mission_id: missionId, member: proposal.member, objective: proposal.objective, verify: proposal.verify, estimate_usd: proposal.estimate_usd || undefined }, proposal.member);
246
- append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
263
+ append(id, 'mission_claimed', { mission_id: missionId, claim_key: key }, proposal.member);
247
264
  if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
248
265
  });
249
266
  return proposals;
@@ -264,8 +281,10 @@ function makeLoop(root, options = {}, deps = {}) {
264
281
  async function startAndRun(id, work) {
265
282
  const current = view(id);
266
283
  const reserve = work.estimate_usd || defaultReserve(current);
284
+ const brief = (deps.brief ? deps.brief(id) : readBrief(root, id)).trim();
285
+ const objective = brief && !work.objective.includes(brief) ? `${work.objective}\n\n${brief}` : work.objective;
267
286
  const started = await cli([
268
- 'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
287
+ 'mission', 'start', objective, '--mission-id', work.mission_id, '--owner', work.member,
269
288
  '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
270
289
  '--max-wall', String(missionMinutes * 60), '--json',
271
290
  ]);
@@ -419,4 +438,4 @@ function makeLoop(root, options = {}, deps = {}) {
419
438
  return { step, tick, run, propose };
420
439
  }
421
440
 
422
- module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readingKey, runCli };
441
+ module.exports = { ACTIVITY_LINE, askOwner, briefPath, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readBrief, readingKey, runCli };
@@ -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 };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const { openingLinesForRoot, renderOpeningLinesBlock } = require('./opening-lines');
4
+
3
5
  const ATRIS_START_MARKER = '<!-- ATRIS:START - Auto-generated, do not edit -->';
4
6
  const ATRIS_END_MARKER = '<!-- ATRIS:END -->';
5
7
 
6
- function renderAtrisClaudeBootBlock() {
8
+ function renderAtrisClaudeBootBlock(openingLines = openingLinesForRoot()) {
9
+ const opening = renderOpeningLinesBlock(openingLines, 'CLAUDE.md');
7
10
  return `${ATRIS_START_MARKER}
8
11
  ## Atris System
9
12
 
@@ -14,12 +17,12 @@ You are now running inside Atris, an AI workspace operating system.
14
17
  After displaying the boot output, respond to the user naturally.
15
18
 
16
19
  The person does not know atris words. Translate their plain English; when unsure run \`atris guide "<their words>"\`.
17
- ${ATRIS_END_MARKER}
20
+ ${opening ? `\n${opening}\n` : ''}${ATRIS_END_MARKER}
18
21
  `;
19
22
  }
20
23
 
21
- function upsertAtrisClaudeBootBlock(source = '') {
22
- const atrisBlock = renderAtrisClaudeBootBlock();
24
+ function upsertAtrisClaudeBootBlock(source = '', options = {}) {
25
+ const atrisBlock = renderAtrisClaudeBootBlock(options.openingLines);
23
26
  const startIdx = source.indexOf('<!-- ATRIS:START');
24
27
  const prependBlock = (preserved) => {
25
28
  const remainder = preserved.replace(/^\n+/, '');