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.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +8 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +263 -11
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/improve.js +3 -2
- package/commands/member.js +28 -3
- package/commands/mission.js +82 -27
- package/commands/pulse.js +89 -5
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/worktree.js +24 -2
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +53 -14
- package/lib/campaign-core.js +98 -17
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +173 -32
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +697 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +77 -29
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-code-provenance.js +118 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/pulse.js +2 -1
- package/lib/qr.js +338 -0
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/commands/improve.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|
package/commands/member.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/commands/mission.js
CHANGED
|
@@ -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
|
-
|
|
1793
|
+
let lines = null;
|
|
1785
1794
|
if (fs.existsSync(skillPath)) {
|
|
1786
1795
|
try {
|
|
1787
|
-
|
|
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
|
|
1797
|
-
|
|
1798
|
-
if (
|
|
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
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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)
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
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 === '
|
|
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
|
|
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 =
|
|
812
|
+
const homeDir = stateHome();
|
|
813
813
|
|
|
814
814
|
const skillFolders = fs.readdirSync(packageSkillsDir).filter(f =>
|
|
815
815
|
fs.statSync(path.join(packageSkillsDir, f)).isDirectory()
|