atris 3.71.2 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -528,7 +528,7 @@ function collectImproveVitals(options = {}, deps = {}) {
528
528
  };
529
529
 
530
530
  // The guarantee gauge: agent landings vs human fixes over the last 14 days.
531
- // Collection is bounded by --since so a huge repo only walks a fortnight.
531
+ // The lookback filters results while traversal can visit older ancestors.
532
532
  // No git history (not a repo, git missing) means the line is omitted silently.
533
533
  let guarantee = null;
534
534
  try {
@@ -1313,7 +1313,8 @@ function collectRevisionSignals(root, options = {}) {
1313
1313
 
1314
1314
  let raw = '';
1315
1315
  try {
1316
- raw = gitLines(root, ['log', `--since=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
1316
+ // Filter dates without pruning ancestors behind an older-dated descendant.
1317
+ raw = gitLines(root, ['log', `--since-as-filter=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
1317
1318
  } catch (e) {
1318
1319
  // a repo with no commits yet exits non-zero on `git log`; that is the
1319
1320
  // empty-history case, not an error. anything else (not a repo) rethrows.
@@ -10636,11 +10636,13 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
10636
10636
  const required = require('../lib/member-task-pick').taskCheck(task).command;
10637
10637
  const checked = mission.verifier_result && mission.verifier_result.passed === true
10638
10638
  && (!required || String(mission.verifier_result.command || '').trim() === required);
10639
- if (!thrown && result && result.started && ['complete', 'ready'].includes(mission.status) && checked) {
10639
+ // The commit comes from the member's own work copy, not the main folder.
10640
+ const workRoot = (result && result.worktree && result.worktree.path) || (mission.worktree && mission.worktree.path) || undefined;
10641
+ const passed = !thrown && result && result.started && ['complete', 'ready'].includes(mission.status) && checked;
10642
+ const changedNothing = passed && selfWrittenJob(task) && !runChangedSomething(taskDb, db, task, result, workRoot);
10643
+ if (passed && !changedNothing) {
10640
10644
  const proof = [`[verified] ${owner} ran mission ${result.mission_id} and its check passed.`,
10641
10645
  receipt ? `Receipt: ${receipt}` : ''].filter(Boolean).join('\n');
10642
- // The commit comes from the member's own work copy, not the main folder.
10643
- const workRoot = (result.worktree && result.worktree.path) || (mission.worktree && mission.worktree.path) || undefined;
10644
10646
  const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof, workRoot });
10645
10647
  if (ready.ready !== false) return { id: task.id, title: task.title, outcome: 'review', receipt: receipt || null };
10646
10648
  // Its change already merged and the merge line closed the job.
@@ -10650,6 +10652,7 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
10650
10652
  const lastReason = String((ticks[ticks.length - 1] || {}).reason || (ticks[ticks.length - 1] || {}).error || '');
10651
10653
  const outOfTime = /timeout|wall-exceeded/.test(lastReason);
10652
10654
  const why = thrown ? thrown.message
10655
+ : changedNothing ? 'the run passed its check but changed nothing: no new commit, no pushed branch, no pull request'
10653
10656
  : outOfTime ? 'ran out of time before the work finished'
10654
10657
  : (result && result.error) || (result && result.started ? 'the check did not pass' : 'the run did not start');
10655
10658
  const reason = String(why || 'the run did not finish').split(/\r?\n/)[0].trim().slice(0, 200);
@@ -10678,6 +10681,28 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
10678
10681
  return { id: task.id, title: task.title, outcome, reason, ...(held && forReview ? { held_for: 'review' } : {}) };
10679
10682
  }
10680
10683
 
10684
+ // A job the member wrote for itself from its mission ("next step toward:").
10685
+ function selfWrittenJob(task) {
10686
+ return String((task && task.source_key) || '').startsWith('self-next:')
10687
+ || (task && task.metadata && task.metadata.source === 'member mission');
10688
+ }
10689
+
10690
+ // Did the run leave something for review? A pushed branch, a pull request,
10691
+ // or a local landing counts, and so does a commit in the work copy that
10692
+ // changes files since the job started. With no start commit on record there
10693
+ // is nothing to compare, so the run is taken at its word.
10694
+ function runChangedSomething(taskDb, db, task, result, workRoot) {
10695
+ const landed = result && result.autoland;
10696
+ if (landed && (landed.pushed || landed.url || landed.merged)) return true;
10697
+ const start = String((taskDb.getTask(db, task.id) || task)?.metadata?.start_commit || '').trim();
10698
+ if (!start) return true;
10699
+ const cwd = workRoot || task.workspace_root || process.cwd();
10700
+ const diff = spawnSync('git', ['diff', '--quiet', start, 'HEAD'], { cwd, encoding: 'utf8' });
10701
+ // Exit 0 means no change. Exit 1 means the code differs; anything else
10702
+ // could not be read, so the run is taken at its word.
10703
+ return diff.status !== 0;
10704
+ }
10705
+
10681
10706
  const MEMBER_GO_STRIKES = 3;
10682
10707
 
10683
10708
  // Failed runs on a task since its last pass, review, reopen, or a person
@@ -64,6 +64,7 @@ const {
64
64
  renderMorningCardRow,
65
65
  } = require('../lib/receipt-block');
66
66
  const { findCachedMissionStepReceipt } = require('../lib/receipt-evidence');
67
+ const { captureCodeBaseline, collectCodeProvenance } = require('../lib/mission-code-provenance');
67
68
  const {
68
69
  pruneRuns,
69
70
  runsPruneLines,
@@ -1773,6 +1774,14 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1773
1774
  return logPath;
1774
1775
  }
1775
1776
 
1777
+ // Shorten at a word boundary so a lesson never ends mid-word.
1778
+ function clipWords(text, max) {
1779
+ if (text.length <= max) return text;
1780
+ const cut = text.slice(0, max + 1);
1781
+ const space = cut.lastIndexOf(' ');
1782
+ return `${(space > max * 0.6 ? cut.slice(0, space) : text.slice(0, max)).replace(/[\s,;:.-]+$/, '')}...`;
1783
+ }
1784
+
1776
1785
  function crystallizeMemberSkillLesson(owner, entry = {}, root = process.cwd()) {
1777
1786
  const dir = memberDir(owner, root);
1778
1787
  if (!dir) return null;
@@ -1781,38 +1790,47 @@ function crystallizeMemberSkillLesson(owner, entry = {}, root = process.cwd()) {
1781
1790
  const skillDir = path.join(dir, 'skills', 'auto-learned');
1782
1791
  fs.mkdirSync(skillDir, { recursive: true });
1783
1792
  const skillPath = path.join(skillDir, 'SKILL.md');
1784
- const existingBullets = [];
1793
+ let lines = null;
1785
1794
  if (fs.existsSync(skillPath)) {
1786
1795
  try {
1787
- const raw = fs.readFileSync(skillPath, 'utf8');
1788
- for (const line of raw.split(/\r?\n/)) {
1789
- if (line.trim().startsWith('- **')) existingBullets.push(line.trim());
1790
- }
1796
+ lines = fs.readFileSync(skillPath, 'utf8').split(/\r?\n/);
1791
1797
  } catch {}
1792
1798
  }
1799
+ const bulletAt = (lines || []).map((line, i) => (line.trim().startsWith('- **') ? i : -1)).filter((i) => i >= 0);
1800
+ const existingBullets = bulletAt.map((i) => lines[i].trim());
1793
1801
  const verifierText = String(entry.verifier || '').replace(/\s+/g, ' ').trim();
1794
1802
  const nextPushText = String(entry.next_push || '').replace(/\s+/g, ' ').trim();
1795
1803
  const layerLabel = String(entry.layer || 'capabilities').trim();
1796
- const parts = [`- **${todayName()} (${layerLabel})**: ${summary.slice(0, 140)}`];
1797
- if (verifierText) parts.push(`Verify: \`${verifierText.slice(0, 80)}\``);
1798
- if (nextPushText) parts.push(`Next push: ${nextPushText.slice(0, 120)}`);
1804
+ const parts = [`- **${todayName()} (${layerLabel})**: ${clipWords(summary, 240)}`];
1805
+ // A cut-off command cannot be run, so a long one is left out, never clipped.
1806
+ if (verifierText && verifierText.length <= 300) parts.push(`Verify: \`${verifierText}\``);
1807
+ if (nextPushText) parts.push(`Next push: ${clipWords(nextPushText, 200)}`);
1799
1808
  const newBullet = parts.join(' -> ');
1800
1809
  const deduped = [
1801
1810
  newBullet,
1802
1811
  ...existingBullets.filter((b) => !b.includes(summary.slice(0, 80))),
1803
1812
  ].slice(0, 5);
1804
1813
 
1805
- const content = [
1806
- '---',
1807
- 'name: auto-learned',
1808
- `description: Verified patterns crystallized from passing runs for ${owner}. Bounded to 5 newest patterns so context never bloats.`,
1809
- '---',
1810
- '',
1811
- '# Auto-Learned Verified Patterns',
1812
- '',
1813
- ...deduped,
1814
- '',
1815
- ].join('\n');
1814
+ let content;
1815
+ if (bulletAt.length) {
1816
+ // Keep whatever a person or reviewer wrote around the lessons; only the
1817
+ // lesson list itself is rewritten.
1818
+ const first = bulletAt[0];
1819
+ const last = bulletAt[bulletAt.length - 1];
1820
+ content = [...lines.slice(0, first), ...deduped, ...lines.slice(last + 1)].join('\n');
1821
+ } else {
1822
+ content = [
1823
+ '---',
1824
+ 'name: auto-learned',
1825
+ `description: Verified patterns crystallized from passing runs for ${owner}. Bounded to 5 newest patterns so context never bloats.`,
1826
+ '---',
1827
+ '',
1828
+ '# Auto-Learned Verified Patterns',
1829
+ '',
1830
+ ...deduped,
1831
+ '',
1832
+ ].join('\n');
1833
+ }
1816
1834
  fs.writeFileSync(skillPath, content, 'utf8');
1817
1835
  return skillPath;
1818
1836
  }
@@ -3198,6 +3216,7 @@ function missionFromArgs(args) {
3198
3216
  '--base',
3199
3217
  '--task',
3200
3218
  '--ask',
3219
+ '--yes-lock',
3201
3220
  '--campaign',
3202
3221
  '--reserve',
3203
3222
  '--mission-id',
@@ -3241,6 +3260,7 @@ function missionFromArgs(args) {
3241
3260
  const taskIds = readRepeatedFlag(args, '--task');
3242
3261
  // MEMBER.md approval-required becomes an open human ask the owner must clear.
3243
3262
  const humanAsks = [...readRepeatedFlag(args, '--ask'), ...memberApprovalAsks(owner, process.cwd(), objective)];
3263
+ const yesLocks = readYesLockSeal(readFlag(args, '--yes-lock', ''), wantsJson(args));
3244
3264
  const drive = missionDrive(readFlag(args, '--destination', ''));
3245
3265
  const alwaysOn = hasFlag(args, '--always-on');
3246
3266
  const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
@@ -3277,6 +3297,8 @@ function missionFromArgs(args) {
3277
3297
  stop_condition: stopCondition,
3278
3298
  task_ids: taskIds,
3279
3299
  human_asks: humanAsks,
3300
+ // the sealed owner steps: the guard holds what each locks until the owner says yes
3301
+ ...(yesLocks.length ? { yes_locks: { steps: yesLocks, sealed_at: stampIso() } } : {}),
3280
3302
  ...(campaignId ? { campaign_id: campaignId } : {}),
3281
3303
  ...(drive ? { drive } : {}),
3282
3304
  next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
@@ -3295,6 +3317,22 @@ function missionFromArgs(args) {
3295
3317
  return mission;
3296
3318
  }
3297
3319
 
3320
+ // --yes-lock '[{"step":3,"text":"...","categories":["publish"]}]': the owner
3321
+ // steps a job may not do before the owner says yes. Bad input stops the start.
3322
+ const YES_LOCK_CATEGORIES = new Set(['publish', 'tag', 'post', 'send', 'merge', 'deploy']);
3323
+ function readYesLockSeal(raw, asJson) {
3324
+ if (!raw) return [];
3325
+ let list;
3326
+ try {
3327
+ list = JSON.parse(raw);
3328
+ } catch {
3329
+ exitMissionError('--yes-lock takes a JSON list of {step, text, categories}', 2, asJson);
3330
+ }
3331
+ const ok = Array.isArray(list) && list.every(l => l && Number.isInteger(l.step) && l.step > 0 && Array.isArray(l.categories) && l.categories.length && l.categories.every(c => YES_LOCK_CATEGORIES.has(c)));
3332
+ if (!ok) exitMissionError('--yes-lock takes a JSON list of {step, text, categories}, with categories from publish, tag, post, send, merge, deploy', 2, asJson);
3333
+ return list.map(l => ({ step: l.step, text: String(l.text || '').slice(0, 200), categories: [...new Set(l.categories)] }));
3334
+ }
3335
+
3298
3336
  function applyMissionStartPatch(mission, patch) {
3299
3337
  if (!patch || typeof patch !== 'object') return mission;
3300
3338
  const { metadata, ...rest } = patch;
@@ -4402,6 +4440,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4402
4440
  nextMission.next_action = `decide next mission, then run: ${nextPlan.command}`;
4403
4441
  nextMission.next_action_preview = nextPlan.preview;
4404
4442
 
4443
+ const codeBaseline = captureCodeBaseline(root);
4405
4444
  ensureMemberMissionFile(nextMission.owner, root, nextMission.objective);
4406
4445
  const { mission: saved } = saveMission(assignMissionNumber(nextMission, root), root, 'mission_continuation_started', {
4407
4446
  parent_mission_id: parent.id,
@@ -4409,7 +4448,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4409
4448
  proof: proof || null,
4410
4449
  });
4411
4450
  if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(root, saved, 0);
4412
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, root);
4451
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, root, codeBaseline);
4413
4452
  const seededAt = stampIso();
4414
4453
  const { mission: updatedParent } = saveMission({
4415
4454
  ...parent,
@@ -4656,6 +4695,7 @@ function startMission(args, options = {}) {
4656
4695
  if (nodeModulesSource) mission.node_modules_source = nodeModulesSource;
4657
4696
  process.chdir(created.path);
4658
4697
  }
4698
+ const codeBaseline = captureCodeBaseline(process.cwd());
4659
4699
  if (mission.xp_task_enabled) {
4660
4700
  const xpTask = createMissionXpTask(mission, process.cwd(), asJson);
4661
4701
  mission.xp_task = xpTask;
@@ -4696,7 +4736,7 @@ function startMission(args, options = {}) {
4696
4736
  lane: saved.lane,
4697
4737
  verifier: saved.verifier,
4698
4738
  });
4699
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
4739
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
4700
4740
  const nextTickCommand = `atris mission tick ${saved.id}${saved.verifier ? ' --verify' : ''}`;
4701
4741
  const payload = {
4702
4742
  ok: true,
@@ -4738,6 +4778,7 @@ function startMission(args, options = {}) {
4738
4778
 
4739
4779
  async function startMissionFromRunObjective(objective, args) {
4740
4780
  const asJson = wantsJson(args);
4781
+ const codeBaseline = captureCodeBaseline(process.cwd());
4741
4782
  const rawObjective = String(objective || '').trim();
4742
4783
  const landRun = hasFlag(args, '--land');
4743
4784
  const dryRun = hasFlag(args, '--dry-run');
@@ -4939,7 +4980,7 @@ async function startMissionFromRunObjective(objective, args) {
4939
4980
  lane: saved.lane,
4940
4981
  verifier: saved.verifier,
4941
4982
  });
4942
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
4983
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
4943
4984
  const completedContinuationGoal = completeActiveContinuationForStartedMission(saved, process.cwd());
4944
4985
  const nativeGoalOptions = codexNativeGoalOptionsFromArgs(args);
4945
4986
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -6629,6 +6670,7 @@ function writeReceipt(mission, result, root = process.cwd()) {
6629
6670
  tree_hash: treeHashFor(root),
6630
6671
  clean: codeState.clean,
6631
6672
  git_tree: codeState.clean ? codeState.tree : null,
6673
+ code_provenance: collectCodeProvenance(root, loadMissionWorktreeBaseline(mission.id, root)?.code),
6632
6674
  mission_id: mission.id,
6633
6675
  objective: mission.objective,
6634
6676
  owner: mission.owner,
@@ -6864,7 +6906,7 @@ function missionBaselinePath(missionId, root = process.cwd()) {
6864
6906
  // Stored outside missions.jsonl because that log re-appends the full mission
6865
6907
  // record on every save. Captured after start bookkeeping so the mission's own
6866
6908
  // state writes land inside the baseline, not in new_since_baseline.
6867
- function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6909
+ function captureMissionWorktreeBaseline(mission, root = process.cwd(), code = captureCodeBaseline(root)) {
6868
6910
  const snapshot = gitWorktreeSnapshot(root);
6869
6911
  if (!snapshot.available) return null;
6870
6912
  const baselineFile = missionBaselinePath(mission.id, root);
@@ -6880,6 +6922,7 @@ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6880
6922
  dirty_count: snapshot.dirty_count,
6881
6923
  dirty_hash: snapshot.dirty_hash,
6882
6924
  paths: Array.from(paths).sort(),
6925
+ code,
6883
6926
  };
6884
6927
  writeJson(baselineFile, baseline);
6885
6928
  return baseline;
@@ -9434,7 +9477,7 @@ function spawnGenericRunnerTick(mission, opts) {
9434
9477
  }
9435
9478
  let gitGuard;
9436
9479
  try {
9437
- gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
9480
+ gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd), missionId: mission.id, yesLockSeal: (mission.yes_locks && mission.yes_locks.steps) || [], stateDir: path.join(require('../lib/state-home').atrisHome(), 'state') });
9438
9481
  } catch (error) {
9439
9482
  resolve({
9440
9483
  ok: false,
@@ -9619,7 +9662,7 @@ function spawnClaudeTick(mission, opts) {
9619
9662
  return new Promise((resolve) => {
9620
9663
  let gitGuard;
9621
9664
  try {
9622
- gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
9665
+ gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd), missionId: mission.id, yesLockSeal: (mission.yes_locks && mission.yes_locks.steps) || [], stateDir: path.join(require('../lib/state-home').atrisHome(), 'state') });
9623
9666
  } catch (error) {
9624
9667
  resolve({
9625
9668
  ok: false,
@@ -10447,7 +10490,7 @@ async function executeMissionRunTicksPhase(context) {
10447
10490
  const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pendingMissionPings(mission), cwd);
10448
10491
  let atris2GitGuard = null;
10449
10492
  try {
10450
- atris2GitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
10493
+ atris2GitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd), missionId: mission.id, yesLockSeal: (mission.yes_locks && mission.yes_locks.steps) || [], stateDir: path.join(require('../lib/state-home').atrisHome(), 'state') });
10451
10494
  } catch (error) {
10452
10495
  result = {
10453
10496
  ...result,
@@ -11275,12 +11318,23 @@ async function runMission(args) {
11275
11318
  context.error = error;
11276
11319
  throw error;
11277
11320
  } finally {
11278
- if (context.lock) completeMissionRunPhase(context);
11321
+ if (context.lock) {
11322
+ completeMissionRunPhase(context);
11323
+ sweepMergedMemberWork(context);
11324
+ }
11279
11325
  }
11280
11326
 
11281
11327
  return context.returnValue;
11282
11328
  }
11283
11329
 
11330
+ // Merged member work closes its jobs even when the team loop is down.
11331
+ function sweepMergedMemberWork(context) {
11332
+ try {
11333
+ const out = require('../lib/member-autoland').sweepAfterMissionRun({ cwd: context.cwd || process.cwd() });
11334
+ if (out && out.closed > 0 && !context.asJson) console.log(out.line);
11335
+ } catch {}
11336
+ }
11337
+
11284
11338
  function tickMission(args) {
11285
11339
  const asJson = wantsJson(args);
11286
11340
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
@@ -12718,6 +12772,7 @@ function missionCommand(args) {
12718
12772
  }
12719
12773
 
12720
12774
  module.exports = {
12775
+ crystallizeMemberSkillLesson,
12721
12776
  listSiblingWorkspaceMissionHints,
12722
12777
  missionProtectedLaneHold,
12723
12778
  missionCommand,
package/commands/pulse.js CHANGED
@@ -53,6 +53,9 @@ Options:
53
53
  --cadence "<cron>" Cron cadence for install
54
54
  --days <n> Auto-expire installed heartbeat after n days
55
55
  --hours <n> Auto-expire installed heartbeat after n hours
56
+ --no-keep-awake Install without holding the Mac awake for the window (macOS)
57
+ --no-autopilot install/tick: never fall back to autopilot (only campaigns and due missions)
58
+ --no-campaigns tick: skip pushing campaigns (atris campaign tick)
56
59
  --model <id> Runner model alias/id for installed heartbeat
57
60
  --runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
58
61
  --runner-bin <path> Runner binary for installed heartbeat
@@ -128,9 +131,43 @@ function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
128
131
  };
129
132
  }
130
133
 
131
- // The full heartbeat composition: continue a due mission, else author+pursue a
132
- // new goal via autopilot. This is the /loop skill's logic, encoded for OS cron.
133
- function runEngine(root, { noClaude = false, autopilotFallback = true, timeoutMs = 600000 } = {}) {
134
+ // Campaigns first: one bounded push on the next campaign here that has work
135
+ // (atris campaign tick). A campaign mission can outlast a mission tick, so it gets
136
+ // up to 50 minutes; the campaign run lock keeps two drivers off one campaign.
137
+ const CAMPAIGN_TICK_MS = 50 * 60 * 1000;
138
+ function runCampaignEngine(root, { timeoutMs = CAMPAIGN_TICK_MS } = {}) {
139
+ const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
140
+ const result = spawnSync(process.execPath, [cliPath, 'campaign', 'tick', '--json'], {
141
+ cwd: root,
142
+ encoding: 'utf8',
143
+ timeout: timeoutMs,
144
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
145
+ });
146
+ let payload = null;
147
+ try {
148
+ payload = JSON.parse(result.stdout || '{}');
149
+ } catch {}
150
+ const idle = Boolean(payload && payload.picked === null);
151
+ return {
152
+ actor: 'campaign_tick',
153
+ ok: result.status === 0,
154
+ reason: idle ? 'no_campaign_work' : (payload && payload.reason) || (result.status === 0 ? 'advanced' : 'error'),
155
+ idle,
156
+ status: result.status,
157
+ payload,
158
+ stdout: String(result.stdout || '').slice(-2000),
159
+ stderr: String(result.stderr || '').slice(-2000),
160
+ };
161
+ }
162
+
163
+ // The full heartbeat composition: push a campaign that has work, else continue a
164
+ // due mission, else author+pursue a new goal via autopilot. This is the /loop
165
+ // skill's logic, encoded for OS cron.
166
+ function runEngine(root, { noClaude = false, autopilotFallback = true, campaigns = true, timeoutMs = 600000 } = {}) {
167
+ if (campaigns && !noClaude) {
168
+ const campaign = runCampaignEngine(root);
169
+ if (!campaign.idle) return campaign;
170
+ }
134
171
  const mission = runMissionEngine(root, { noClaude, timeoutMs });
135
172
  if (pulse.shouldFallbackToAutopilot({ missionReason: mission.reason, autopilotFallback, noClaude })) {
136
173
  const ap = runAutopilotTick(root, { timeoutMs });
@@ -284,7 +321,7 @@ function tickCommand(args, root = process.cwd()) {
284
321
  // filesystem snapshot when git is absent.
285
322
  const isRepo = fs.existsSync(path.join(root, '.git'));
286
323
  const before = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
287
- engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
324
+ engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot'), campaigns: !hasFlag(args, '--no-campaigns') });
288
325
  const after = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
289
326
  // This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
290
327
  // Pre-existing dirt is excluded so reward isn't re-credited every tick.
@@ -315,7 +352,9 @@ function tickCommand(args, root = process.cwd()) {
315
352
  const changedTail = committed
316
353
  ? ' - committed'
317
354
  : (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
318
- const what = engine.actor === 'autopilot'
355
+ const what = engine.actor === 'campaign_tick'
356
+ ? `campaign ${(engine.payload && engine.payload.campaign && engine.payload.campaign.id) || ''} ${engine.reason}${changedTail}`.replace(/\s+/g, ' ')
357
+ : engine.actor === 'autopilot'
319
358
  ? `autopilot authored/advanced a goal${changedTail}`
320
359
  : engine.reason === 'no_due_mission'
321
360
  ? 'no due mission; heartbeat alive (no-op)'
@@ -677,6 +716,8 @@ function installCommand(args, root = process.cwd(), options = {}) {
677
716
  pathDirs,
678
717
  launchLabel: label,
679
718
  launchPlistPath: plistPath,
719
+ // Baked into every tick: a night of campaigns only, no invented goals.
720
+ tickFlags: ['--no-autopilot', '--no-campaigns'].filter((flag) => hasFlag(args, flag)),
680
721
  });
681
722
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
682
723
 
@@ -695,6 +736,10 @@ function installCommand(args, root = process.cwd(), options = {}) {
695
736
  const apply = commandSpawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8', timeout: 10000 });
696
737
  const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
697
738
  const cadenceMapping = pulse.launchIntervalFromCadence({ cron });
739
+ // Tests pass their own spawnSync and never get a real caffeinate unless they ask.
740
+ const wantsKeepAwake = apply.status === 0 && process.platform === 'darwin' && !hasFlag(args, '--no-keep-awake')
741
+ && (!options.spawnSync || options.holdKeepAwake);
742
+ const keepAwake = wantsKeepAwake ? (options.holdKeepAwake || holdKeepAwake)(slot.stateHome, expiry.seconds) : null;
698
743
 
699
744
  const out = {
700
745
  ok: apply.status === 0,
@@ -718,6 +763,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
718
763
  runner_bin: runnerBin || null,
719
764
  runner_template_configured: Boolean(runnerCommandTemplate),
720
765
  expiry_breadcrumb_cleared: expiryBreadcrumbCleared,
766
+ keep_awake: keepAwake,
721
767
  };
722
768
  if (!asJson) {
723
769
  if (apply.status === 0) {
@@ -728,6 +774,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
728
774
  `script: ${scriptPath}`,
729
775
  cadenceMapping.fallback ? `cadence '${cron}' is not directly mappable; using 3600 seconds.` : '',
730
776
  `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
777
+ ...(keepAwake ? [`keeps this Mac awake until then (lid open); atris pulse uninstall lets it sleep.`] : []),
731
778
  ].filter(Boolean).join('\n') + '\n');
732
779
  } else {
733
780
  writeOutput(`pulse install failed to bootstrap ${label}: ${apply.stderr || apply.status}\nplist: ${plistPath}\n`);
@@ -736,6 +783,40 @@ function installCommand(args, root = process.cwd(), options = {}) {
736
783
  return emit(out, asJson);
737
784
  }
738
785
 
786
+ // The heartbeat only ticks while the Mac is awake. On macOS an overnight install
787
+ // holds a no-idle-sleep assertion (caffeinate -i) for exactly the heartbeat's
788
+ // window, so a Mac on its own power settings does not sleep through the night.
789
+ // Closing the lid still sleeps. Uninstall lets it go. --no-keep-awake skips it.
790
+ function keepAwakeFile(stateHome) {
791
+ return path.join(stateHome, 'keep-awake.pid');
792
+ }
793
+
794
+ function releaseKeepAwake(stateHome) {
795
+ const file = keepAwakeFile(stateHome);
796
+ let pid = 0;
797
+ try { pid = Number(fs.readFileSync(file, 'utf8').trim()); } catch { return false; }
798
+ fs.rmSync(file, { force: true });
799
+ if (!Number.isInteger(pid) || pid <= 0) return false;
800
+ try {
801
+ const name = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
802
+ if (!/caffeinate$/.test(name)) return false;
803
+ process.kill(pid, 'SIGTERM');
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+
810
+ function holdKeepAwake(stateHome, seconds) {
811
+ releaseKeepAwake(stateHome);
812
+ const { spawn } = require('child_process');
813
+ const child = spawn('caffeinate', ['-i', '-t', String(Math.max(60, Math.floor(seconds)))], { detached: true, stdio: 'ignore' });
814
+ child.unref();
815
+ fs.mkdirSync(stateHome, { recursive: true });
816
+ fs.writeFileSync(keepAwakeFile(stateHome), `${child.pid}\n`);
817
+ return { pid: child.pid, seconds: Math.floor(seconds) };
818
+ }
819
+
739
820
  function uninstallCommand(args, root = process.cwd(), options = {}) {
740
821
  const asJson = wantsJson(args);
741
822
  const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
@@ -745,6 +826,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
745
826
  const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
746
827
  const uid = options.uid != null ? options.uid : process.getuid();
747
828
  commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
829
+ const keepAwakeReleased = (options.releaseKeepAwake || releaseKeepAwake)(slot.stateHome || slot.activeStateHome);
748
830
  const plistHad = fs.existsSync(plistPath);
749
831
  fs.rmSync(plistPath, { force: true });
750
832
  const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
@@ -757,6 +839,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
757
839
  ok: apply.status === 0,
758
840
  action: 'pulse_uninstall',
759
841
  removed: plistHad || had,
842
+ keep_awake_released: keepAwakeReleased,
760
843
  label,
761
844
  plist_path: plistPath,
762
845
  removed_legacy_crontab: had,
@@ -818,5 +901,6 @@ module.exports = {
818
901
  pulseExpiryBreadcrumbPath,
819
902
  installCommand,
820
903
  runCommand,
904
+ uninstallCommand,
821
905
  refreshOrbPolicyLessons,
822
906
  };
package/commands/sync.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { stateHome } = require('../lib/state-home');
4
4
  const { ensureWikiScaffold } = require('../lib/wiki');
5
5
  const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
6
6
  const { ensureRunIgnore } = require('../lib/gitignore');
@@ -809,7 +809,7 @@ function syncSkills({ silent = false } = {}) {
809
809
  }
810
810
 
811
811
  let updated = 0;
812
- const homeDir = os.homedir();
812
+ const homeDir = stateHome();
813
813
 
814
814
  const skillFolders = fs.readdirSync(packageSkillsDir).filter(f =>
815
815
  fs.statSync(path.join(packageSkillsDir, f)).isDirectory()