atris 3.34.0 → 3.36.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/AGENTS.md +35 -0
- package/FOR_AGENTS.md +5 -3
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +35 -8
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +6 -2
- package/ax +617 -31
- package/bin/atris.js +719 -266
- package/commands/activate.js +194 -88
- package/commands/aeo.js +52 -0
- package/commands/agents.js +166 -0
- package/commands/autoland.js +718 -72
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/business.js +91 -8
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/codex-goal.js +26 -2
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/drive.js +187 -0
- package/commands/engine.js +1061 -29
- package/commands/experiments.js +28 -0
- package/commands/feed.js +202 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +285 -3
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +83 -43
- package/commands/integrations.js +39 -11
- package/commands/interview.js +209 -0
- package/commands/land.js +253 -52
- package/commands/lesson.js +112 -1
- package/commands/lifecycle.js +39 -3
- package/commands/linear.js +38 -0
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +899 -66
- package/commands/mission.js +3512 -332
- package/commands/next.js +137 -0
- package/commands/now.js +240 -21
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/radar.js +259 -14
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/serve.js +54 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/status.js +50 -5
- package/commands/stream.js +861 -0
- package/commands/stripe.js +38 -0
- package/commands/study.js +693 -0
- package/commands/supabase.js +39 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +2275 -182
- package/commands/team.js +73 -0
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +299 -20
- package/commands/write.js +399 -0
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +391 -43
- package/lib/autoland.js +353 -52
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2431 -38
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/memory-view.js +14 -5
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/next-moves.js +327 -10
- package/lib/official-cli-integration.js +174 -0
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/review-integrity.js +147 -0
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +33 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +319 -27
- package/lib/task-proof.js +43 -1
- package/lib/task-receipt.js +93 -0
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
package/commands/autoland.js
CHANGED
|
@@ -6,7 +6,12 @@ const os = require('os');
|
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
7
|
|
|
8
8
|
const autoland = require('../lib/autoland');
|
|
9
|
-
const {
|
|
9
|
+
const { gateForHuman } = require('../lib/voice-gate');
|
|
10
|
+
const { evaluateAutoAccept } = require('../lib/auto-accept-certified');
|
|
11
|
+
const { operatorReady, hasAgentJargon, explainResult } = autoland;
|
|
12
|
+
const MISSION_AUTO_VERIFY_STATUSES = new Set(['planning', 'paused', 'ready']);
|
|
13
|
+
const CLOSED_TASK_STATUSES = new Set(['done', 'archived']);
|
|
14
|
+
const MAX_MISSION_AUTO_VERIFY_PER_TICK = 3;
|
|
10
15
|
|
|
11
16
|
function repoRoot(cwd = process.cwd()) {
|
|
12
17
|
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
@@ -18,20 +23,100 @@ function projectName(root) {
|
|
|
18
23
|
return path.basename(root);
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
function missionReasonBreakdown(rows, fallback = 'stale') {
|
|
27
|
+
const tally = {};
|
|
28
|
+
for (const row of rows || []) {
|
|
29
|
+
const reason = String(row?.reason || row?.pause_reason || fallback).trim() || fallback;
|
|
30
|
+
tally[reason] = (tally[reason] || 0) + 1;
|
|
31
|
+
}
|
|
32
|
+
return tally;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mergeReasonBreakdown(left = {}, right = {}) {
|
|
36
|
+
const merged = { ...(left && typeof left === 'object' ? left : {}) };
|
|
37
|
+
for (const [reason, count] of Object.entries(right || {})) {
|
|
38
|
+
merged[reason] = (Number(merged[reason]) || 0) + (Number(count) || 0);
|
|
39
|
+
}
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mergeHeldMissions(left = {}, held = []) {
|
|
44
|
+
const merged = { ...(left && typeof left === 'object' ? left : {}) };
|
|
45
|
+
for (const row of held || []) {
|
|
46
|
+
if (!row || !row.id) continue;
|
|
47
|
+
merged[row.id] = {
|
|
48
|
+
id: row.id,
|
|
49
|
+
owner: row.owner || null,
|
|
50
|
+
objective: row.objective || '',
|
|
51
|
+
reason: row.reason || 'operator-required',
|
|
52
|
+
resume_command: row.resume_command || `atris mission run ${row.id}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return merged;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recordJanitorState(state, { stopped = [], held = [], worktrees = 0 } = {}) {
|
|
59
|
+
const stoppedCount = Array.isArray(stopped) ? stopped.length : Number(stopped) || 0;
|
|
60
|
+
const heldRows = Array.isArray(held) ? held : [];
|
|
61
|
+
const worktreeCount = Number(worktrees) || 0;
|
|
62
|
+
if (!stoppedCount && !heldRows.length && !worktreeCount) return;
|
|
63
|
+
const prior = state.janitor && typeof state.janitor === 'object' ? state.janitor : {};
|
|
64
|
+
const missionHolds = mergeHeldMissions(prior.mission_holds, heldRows);
|
|
65
|
+
const missionStopReasons = mergeReasonBreakdown(prior.mission_stop_reasons, missionReasonBreakdown(stopped, 'stale'));
|
|
66
|
+
state.janitor = {
|
|
67
|
+
...prior,
|
|
68
|
+
missions_stopped: (Number(prior.missions_stopped) || 0) + stoppedCount,
|
|
69
|
+
worktrees_reaped: (Number(prior.worktrees_reaped) || 0) + worktreeCount,
|
|
70
|
+
...(Object.keys(missionStopReasons).length ? { mission_stop_reasons: missionStopReasons } : {}),
|
|
71
|
+
...(Object.keys(missionHolds).length ? { mission_holds: missionHolds, missions_held: Object.keys(missionHolds).length } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
// The digest's "next, if you agree" section: top candidate moves from Atris
|
|
22
76
|
// state, each with the member best suited to own it. Moves that can't explain
|
|
23
77
|
// themselves are counted, not shown. Never blocks the digest.
|
|
78
|
+
function missionHasBudgetRemaining(mission, nowMs = Date.now()) {
|
|
79
|
+
if (mission?.budget_contract?.policy !== 'spend_full_budget') return false;
|
|
80
|
+
const seconds = Number(mission?.budget_contract?.requested_seconds || mission?.max_wall_seconds || 0);
|
|
81
|
+
const startedMs = Date.parse(mission?.started_at || mission?.created_at || mission?.updated_at || '');
|
|
82
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
83
|
+
&& Number.isFinite(startedMs) && startedMs + seconds * 1000 > nowMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizedMissionObjective(mission) {
|
|
87
|
+
return String(mission?.objective || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
24
90
|
function digestNextMoves(root) {
|
|
25
91
|
try {
|
|
26
92
|
const { nextMoves } = require('../lib/next-moves');
|
|
27
93
|
const { resolveFunctionalOwner } = require('../lib/functional-owner');
|
|
28
|
-
const
|
|
29
|
-
const
|
|
94
|
+
const { listMissions } = require('./mission');
|
|
95
|
+
const missionRows = listMissions(root) || [];
|
|
96
|
+
const missions = new Map(missionRows.map((mission) => [mission.id, mission]));
|
|
97
|
+
const activeBudgetObjectives = new Set(missionRows
|
|
98
|
+
.filter((mission) => missionHasBudgetRemaining(mission))
|
|
99
|
+
.map(normalizedMissionObjective)
|
|
100
|
+
.filter(Boolean));
|
|
101
|
+
const all = (nextMoves(root, 5) || [])
|
|
102
|
+
.filter((move) => move && move.title)
|
|
103
|
+
.filter((move) => {
|
|
104
|
+
if (move.kind !== 'mission_ready') return true;
|
|
105
|
+
const mission = missions.get(move.ref);
|
|
106
|
+
return !missionHasBudgetRemaining(mission)
|
|
107
|
+
&& !activeBudgetObjectives.has(normalizedMissionObjective(mission));
|
|
108
|
+
});
|
|
109
|
+
const explainable = all.filter((move) => move.kind === 'mission_ready' || operatorReady(move.title));
|
|
110
|
+
const ready = explainable.slice(0, 3).map((move) => {
|
|
30
111
|
let owner = null;
|
|
31
|
-
|
|
32
|
-
|
|
112
|
+
const mission = move.kind === 'mission_ready' ? missions.get(move.ref) : null;
|
|
113
|
+
if (mission?.owner) owner = mission.owner;
|
|
114
|
+
else {
|
|
115
|
+
try { owner = resolveFunctionalOwner({ title: move.title, root })?.owner || null; } catch {}
|
|
116
|
+
}
|
|
117
|
+
return { title: move.title, owner, kind: move.kind || null, label: move.label || null };
|
|
33
118
|
});
|
|
34
|
-
return { moves: ready, unexplained: all.length -
|
|
119
|
+
return { moves: ready, unexplained: all.length - explainable.length };
|
|
35
120
|
} catch {
|
|
36
121
|
return { moves: [], unexplained: 0 };
|
|
37
122
|
}
|
|
@@ -62,6 +147,158 @@ function readProjection(root) {
|
|
|
62
147
|
}
|
|
63
148
|
}
|
|
64
149
|
|
|
150
|
+
function readAcceptedTaskHistory(root, fallbackTasks = [], dbPath = undefined) {
|
|
151
|
+
try {
|
|
152
|
+
const taskDb = require('../lib/task-db');
|
|
153
|
+
const db = taskDb.open(dbPath);
|
|
154
|
+
const rows = taskDb.listTasks(db, { workspaceRoot: root });
|
|
155
|
+
return taskDb.withTaskDisplayRefs(rows);
|
|
156
|
+
} catch {
|
|
157
|
+
return fallbackTasks;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function digestStoryRows(accepted, acceptedTasks) {
|
|
162
|
+
const byRef = new Map((acceptedTasks || []).map((task) => [task.display_id || task.legacy_ref || task.id, task]));
|
|
163
|
+
return (accepted?.auto || []).map((item) => {
|
|
164
|
+
const summary = autoland.digestLine(item);
|
|
165
|
+
if (!summary || !operatorReady(summary)) return null;
|
|
166
|
+
const task = byRef.get(item.ref);
|
|
167
|
+
const candidates = [
|
|
168
|
+
task?.review?.landing?.happened || task?.metadata?.landing_happened || '',
|
|
169
|
+
item.result,
|
|
170
|
+
item.happened,
|
|
171
|
+
autoland.clarify(item.title, 160),
|
|
172
|
+
summary,
|
|
173
|
+
].map((candidate) => autoland.historicalLandingText(candidate, 160));
|
|
174
|
+
return { item, story: candidates.find((candidate) => operatorReady(candidate)) || summary };
|
|
175
|
+
}).filter(Boolean);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function compactId(value) {
|
|
179
|
+
return String(value || '').trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function taskIdentityRefs(task) {
|
|
183
|
+
return [
|
|
184
|
+
task?.id,
|
|
185
|
+
task?.display_id,
|
|
186
|
+
task?.legacy_ref,
|
|
187
|
+
task?.ref,
|
|
188
|
+
task?.task_id,
|
|
189
|
+
task?.metadata?.task_id,
|
|
190
|
+
task?.metadata?.display_id,
|
|
191
|
+
task?.metadata?.ref,
|
|
192
|
+
].map(compactId).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function taskGoalRefs(task) {
|
|
196
|
+
return [
|
|
197
|
+
task?.goal_id,
|
|
198
|
+
task?.goalId,
|
|
199
|
+
task?.mission_id,
|
|
200
|
+
task?.missionId,
|
|
201
|
+
task?.metadata?.goal_id,
|
|
202
|
+
task?.metadata?.goalId,
|
|
203
|
+
task?.metadata?.mission_id,
|
|
204
|
+
task?.metadata?.missionId,
|
|
205
|
+
task?.metadata?.goal?.id,
|
|
206
|
+
].map(compactId).filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function missionTaskRefs(mission) {
|
|
210
|
+
return [
|
|
211
|
+
...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
|
|
212
|
+
mission?.task_id,
|
|
213
|
+
mission?.current_task_id,
|
|
214
|
+
mission?.task_ref,
|
|
215
|
+
mission?.xp_task?.task_id,
|
|
216
|
+
mission?.xp_task?.ref,
|
|
217
|
+
].map(compactId).filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function attachedTasksForMission(mission, tasks) {
|
|
221
|
+
const missionId = compactId(mission?.id);
|
|
222
|
+
const taskRefs = new Set(missionTaskRefs(mission));
|
|
223
|
+
return (tasks || []).filter((task) => {
|
|
224
|
+
if (missionId && taskGoalRefs(task).includes(missionId)) return true;
|
|
225
|
+
if (taskRefs.size === 0) return false;
|
|
226
|
+
return taskIdentityRefs(task).some((ref) => taskRefs.has(ref));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function taskIsClosed(task) {
|
|
231
|
+
return CLOSED_TASK_STATUSES.has(String(task?.status || '').trim().toLowerCase());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function missionHasVerifier(mission) {
|
|
235
|
+
return Boolean(String(mission?.verifier || mission?.effective_verifier || '').trim());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function missionReadyForClosedTaskVerify(mission, tasks) {
|
|
239
|
+
const status = String(mission?.status || '').trim().toLowerCase();
|
|
240
|
+
if (!MISSION_AUTO_VERIFY_STATUSES.has(status)) return false;
|
|
241
|
+
if (!missionHasVerifier(mission)) return false;
|
|
242
|
+
if (mission?.verifier_result?.passed === true) return false;
|
|
243
|
+
const attached = attachedTasksForMission(mission, tasks);
|
|
244
|
+
return attached.length > 0 && attached.every(taskIsClosed);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseMissionVerifyResult(id, result) {
|
|
248
|
+
let payload = null;
|
|
249
|
+
try {
|
|
250
|
+
payload = JSON.parse(result.stdout || '{}');
|
|
251
|
+
} catch {}
|
|
252
|
+
const verifier = payload?.verifier_result || null;
|
|
253
|
+
const passed = verifier ? verifier.passed === true : null;
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
result: passed === true ? 'passed' : passed === false ? 'failed' : (payload?.action || (result.status === 0 ? 'tick_recorded' : 'tick_failed')),
|
|
257
|
+
status: payload?.mission?.status || null,
|
|
258
|
+
receipt_path: payload?.receipt_path || null,
|
|
259
|
+
exit_status: result.status,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function verifyClosedTaskMissions(root, tasks, { limit = MAX_MISSION_AUTO_VERIFY_PER_TICK } = {}) {
|
|
264
|
+
const verified = [];
|
|
265
|
+
const errors = [];
|
|
266
|
+
let candidates = [];
|
|
267
|
+
try {
|
|
268
|
+
const { listMissions } = require('./mission');
|
|
269
|
+
candidates = listMissions(root).filter((mission) => missionReadyForClosedTaskVerify(mission, tasks)).slice(0, limit);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return {
|
|
272
|
+
verified,
|
|
273
|
+
errors: [{ error: String((err && err.message) || err).slice(0, 200) }],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const mission of candidates) {
|
|
278
|
+
const result = runOwnCli(root, [
|
|
279
|
+
'mission',
|
|
280
|
+
'tick',
|
|
281
|
+
mission.id,
|
|
282
|
+
'--verify',
|
|
283
|
+
'--summary',
|
|
284
|
+
'All linked repair tasks are closed; autoland re-ran the mission verifier.',
|
|
285
|
+
'--json',
|
|
286
|
+
]);
|
|
287
|
+
const outcome = parseMissionVerifyResult(mission.id, result);
|
|
288
|
+
verified.push(outcome);
|
|
289
|
+
if (result.status !== 0 || outcome.result === 'failed' || outcome.result === 'tick_failed') {
|
|
290
|
+
errors.push({
|
|
291
|
+
id: mission.id,
|
|
292
|
+
result: outcome.result,
|
|
293
|
+
status: result.status,
|
|
294
|
+
stderr: String(result.stderr || '').slice(0, 200),
|
|
295
|
+
stdout: String(result.stdout || '').slice(-500),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { verified, errors };
|
|
300
|
+
}
|
|
301
|
+
|
|
65
302
|
function landSummarySafe(root) {
|
|
66
303
|
try {
|
|
67
304
|
return require('./land').landSummary(root);
|
|
@@ -70,28 +307,147 @@ function landSummarySafe(root) {
|
|
|
70
307
|
}
|
|
71
308
|
}
|
|
72
309
|
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
310
|
+
function refreshLandingRefs(root) {
|
|
311
|
+
const remotes = spawnSync('git', ['remote'], { cwd: root, encoding: 'utf8', timeout: 10000 });
|
|
312
|
+
if (remotes.status !== 0 || !remotes.stdout.split(/\r?\n/).includes('origin')) return;
|
|
313
|
+
// One round-trip for both; a missing branch doesn't fail the other with a
|
|
314
|
+
// multi-ref fetch on any git that supports it, and fetch errors are ignored
|
|
315
|
+
// here anyway.
|
|
316
|
+
const both = spawnSync('git', ['fetch', 'origin', 'master', 'main'], { cwd: root, encoding: 'utf8', timeout: 30000 });
|
|
317
|
+
if (both.status === 0) return;
|
|
318
|
+
for (const branch of ['master', 'main']) {
|
|
319
|
+
spawnSync('git', ['fetch', 'origin', branch], { cwd: root, encoding: 'utf8', timeout: 30000 });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function sweepLanding(root, { ttlDays, staleHours, now = Date.now() } = {}) {
|
|
324
|
+
refreshLandingRefs(root);
|
|
325
|
+
const { collectBoard, reap } = require('./land');
|
|
326
|
+
// light: this board only feeds .branches staleness + .summary; reap builds
|
|
327
|
+
// its own full board for dirty-count salvage decisions.
|
|
328
|
+
const beforeBoard = collectBoard(root, { ttlDays, staleHours, now, light: true });
|
|
329
|
+
const stale = beforeBoard.branches
|
|
330
|
+
.filter((b) => b.state === 'active' && b.stale)
|
|
331
|
+
.sort((a, b) => b.activityHours - a.activityHours)
|
|
332
|
+
.map((b) => ({
|
|
333
|
+
name: b.name,
|
|
334
|
+
ahead: b.ahead,
|
|
335
|
+
ageHours: b.ageHours,
|
|
336
|
+
activityHours: b.activityHours,
|
|
337
|
+
}));
|
|
338
|
+
const reaped = reap(root, {
|
|
339
|
+
ttlDays,
|
|
340
|
+
staleHours,
|
|
341
|
+
remote: false,
|
|
342
|
+
includeDetached: false,
|
|
343
|
+
now,
|
|
344
|
+
});
|
|
345
|
+
const afterBoard = collectBoard(root, { ttlDays, staleHours, now, light: true });
|
|
346
|
+
const human = [];
|
|
347
|
+
for (const kept of reaped.keptWorktrees || []) {
|
|
348
|
+
if (String(kept).includes('(fresh_worktree_grace)')) continue;
|
|
349
|
+
if (String(kept).includes('(current_checkout)')) continue;
|
|
350
|
+
human.push(String(kept));
|
|
351
|
+
}
|
|
352
|
+
for (const moved of reaped.keptMovedBranches || []) human.push(`${moved} moved during cleanup`);
|
|
353
|
+
if (reaped.bundleError) human.push(`backup failed: ${reaped.bundleError}`);
|
|
354
|
+
return {
|
|
355
|
+
at: new Date(now).toISOString(),
|
|
356
|
+
before: beforeBoard.summary,
|
|
357
|
+
after: afterBoard.summary,
|
|
358
|
+
stale,
|
|
359
|
+
human,
|
|
360
|
+
reaped: {
|
|
361
|
+
branches: reaped.deletedBranches.length,
|
|
362
|
+
worktrees: reaped.removedWorktrees.length,
|
|
363
|
+
bundle: reaped.bundle,
|
|
364
|
+
patches: reaped.patches.length,
|
|
365
|
+
untracked: (reaped.untracked || []).length,
|
|
366
|
+
bundleError: reaped.bundleError || null,
|
|
367
|
+
keptWorktrees: reaped.keptWorktrees || [],
|
|
368
|
+
keptMovedBranches: reaped.keptMovedBranches || [],
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function recordLandingSweepState(state, sweep, { error = null } = {}) {
|
|
374
|
+
const at = sweep?.at || new Date().toISOString();
|
|
375
|
+
const today = at.slice(0, 10);
|
|
376
|
+
state.last_reap_date = today;
|
|
377
|
+
state.last_reap_at = at;
|
|
378
|
+
if (error) {
|
|
379
|
+
state.last_reap_error = { date: today, error };
|
|
380
|
+
delete state.landing_sweep;
|
|
381
|
+
return;
|
|
82
382
|
}
|
|
383
|
+
delete state.last_reap_error;
|
|
384
|
+
state.landing_sweep = {
|
|
385
|
+
at,
|
|
386
|
+
before: sweep.before,
|
|
387
|
+
after: sweep.after,
|
|
388
|
+
reaped: sweep.reaped,
|
|
389
|
+
stale_count: sweep.stale.length,
|
|
390
|
+
stale: sweep.stale.slice(0, 10),
|
|
391
|
+
human_count: sweep.human.length,
|
|
392
|
+
human: sweep.human.slice(0, 10),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function wishSweepSummaryLine(summary, error = null) {
|
|
397
|
+
if (error) return `wishes: sweep failed (${error})`;
|
|
398
|
+
const dispatched = Number(summary?.dispatched) || 0;
|
|
399
|
+
const fulfilled = Number(summary?.fulfilled) || 0;
|
|
400
|
+
const waiting = Number(summary?.waiting_on_operator) || 0;
|
|
401
|
+
const noExecutor = Number(summary?.skipped_no_executor) || 0;
|
|
402
|
+
const capped = Number(summary?.capped) || 0;
|
|
403
|
+
let line = `wishes: ${dispatched} dispatched, ${waiting} waiting on operator, ${fulfilled} fulfilled`;
|
|
404
|
+
const notes = [];
|
|
405
|
+
if (noExecutor > 0) notes.push(`${noExecutor} need a working builder`);
|
|
406
|
+
if (capped > 0) notes.push(`${capped} held for the next tick`);
|
|
407
|
+
if (notes.length) line += ` (${notes.join(', ')})`;
|
|
408
|
+
return line;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function evaluateQueue(root, { strictVerify, acceptAll }) {
|
|
412
|
+
return readProjection(root)
|
|
413
|
+
.filter((task) => task && task.status === 'review')
|
|
414
|
+
.filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
|
|
415
|
+
.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))
|
|
416
|
+
.slice(0, 50)
|
|
417
|
+
.map((task) => {
|
|
418
|
+
const evaluation = evaluateAutoAccept(
|
|
419
|
+
{ ...task, workspace_root: root },
|
|
420
|
+
{ strictVerify, acceptAll, executeVerify: false },
|
|
421
|
+
);
|
|
422
|
+
return {
|
|
423
|
+
...evaluation,
|
|
424
|
+
action: evaluation.eligible ? 'would_accept' : 'skipped',
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function protectedReviewWaiting(root, policy = {}, waiting = []) {
|
|
430
|
+
const alreadyWaiting = new Set((waiting || []).map((row) => row.ref));
|
|
431
|
+
return evaluateQueue(root, {
|
|
432
|
+
strictVerify: policy.strict_verify !== false,
|
|
433
|
+
acceptAll: Boolean(policy.accept_all),
|
|
434
|
+
})
|
|
435
|
+
.filter((row) => /^denied_tag_/.test(String(row.reason || '')))
|
|
436
|
+
.filter((row) => !alreadyWaiting.has(row.ref));
|
|
83
437
|
}
|
|
84
438
|
|
|
85
439
|
function plainReason(reason) {
|
|
86
440
|
const map = {
|
|
87
|
-
denied_tag_billing: 'money
|
|
88
|
-
denied_tag_deploy: 'a deploy
|
|
89
|
-
denied_tag_security: 'security
|
|
90
|
-
denied_tag_customer: 'customer-facing
|
|
91
|
-
denied_tag_external: 'outward-facing
|
|
92
|
-
denied_tag_feedback: 'customer feedback
|
|
93
|
-
denied_tag_voice: 'voice/comms — yours to approve',
|
|
441
|
+
denied_tag_billing: 'money: human decision required',
|
|
442
|
+
denied_tag_deploy: 'a deploy: human decision required',
|
|
443
|
+
denied_tag_security: 'security: human decision required',
|
|
444
|
+
denied_tag_customer: 'customer-facing: human decision required',
|
|
445
|
+
denied_tag_external: 'outward-facing: human decision required',
|
|
446
|
+
denied_tag_feedback: 'customer feedback: human decision required',
|
|
94
447
|
needs_second_reviewer_or_third_pass: 'needs one more independent check first',
|
|
448
|
+
needs_independent_reviewer: 'built and judged by the same actor, needs an independent check',
|
|
449
|
+
verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
|
|
450
|
+
judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
|
|
95
451
|
not_agent_certified: 'not certified yet',
|
|
96
452
|
insufficient_review_passes: 'not enough review passes yet',
|
|
97
453
|
strict_verify_missing: 'no recorded check command to re-run',
|
|
@@ -106,19 +462,25 @@ function showStatus(root, args) {
|
|
|
106
462
|
const policy = autoland.readPolicy(root);
|
|
107
463
|
const enabled = Boolean(policy && policy.enabled);
|
|
108
464
|
const strictVerify = policy ? policy.strict_verify !== false : true;
|
|
109
|
-
const
|
|
465
|
+
const acceptAll = Boolean(policy && policy.accept_all);
|
|
466
|
+
const results = evaluateQueue(root, { strictVerify, acceptAll })
|
|
110
467
|
.filter((r) => r.reason !== 'not_in_review');
|
|
111
|
-
const
|
|
468
|
+
const readyForRecheck = results.filter((r) => r.action === 'would_accept');
|
|
469
|
+
const wouldLand = readyForRecheck.filter((r) => r.verification_pending !== true);
|
|
112
470
|
const blocked = results.filter((r) => r.action !== 'would_accept');
|
|
113
471
|
const tasks = readProjection(root);
|
|
114
472
|
const waiting = autoland.waitingOnHuman(tasks);
|
|
473
|
+
const heartbeatInstalled = typeof policy?.heartbeat_installed === 'boolean'
|
|
474
|
+
? policy.heartbeat_installed
|
|
475
|
+
: null;
|
|
115
476
|
|
|
116
477
|
if (json) {
|
|
117
478
|
console.log(JSON.stringify({
|
|
118
479
|
enabled,
|
|
119
480
|
policy,
|
|
120
|
-
heartbeat_installed:
|
|
481
|
+
heartbeat_installed: heartbeatInstalled,
|
|
121
482
|
would_land: wouldLand,
|
|
483
|
+
ready_for_recheck: readyForRecheck,
|
|
122
484
|
blocked,
|
|
123
485
|
waiting_on_human: waiting,
|
|
124
486
|
}, null, 2));
|
|
@@ -126,27 +488,41 @@ function showStatus(root, args) {
|
|
|
126
488
|
}
|
|
127
489
|
|
|
128
490
|
console.log('');
|
|
129
|
-
console.log(
|
|
491
|
+
console.log('autoland: certified work lands itself; you keep the irreversible calls');
|
|
130
492
|
console.log('');
|
|
131
|
-
|
|
132
|
-
|
|
493
|
+
const policyOwner = String(policy?.enabled_by || 'unknown').trim() || 'unknown';
|
|
494
|
+
const policyText = enabled
|
|
495
|
+
? (policy.default
|
|
496
|
+
? `on by default for this workspace (no policy set; would accept as ${policyOwner}). set it: atris autoland on`
|
|
497
|
+
: `on (flipped by ${policyOwner}${policy.enabled_at ? ` on ${String(policy.enabled_at).slice(0, 10)}` : ''})`)
|
|
498
|
+
: 'off - everything waits for you';
|
|
499
|
+
console.log(` policy: ${policyText}`);
|
|
500
|
+
if (enabled && acceptAll) console.log(' bar: everything lands except the protected lanes (money, deploys, security, customer, outward)');
|
|
501
|
+
const heartbeatText = heartbeatInstalled === true
|
|
502
|
+
? 'running hourly'
|
|
503
|
+
: heartbeatInstalled === false
|
|
504
|
+
? 'not installed'
|
|
505
|
+
: 'unknown - run atris autoland on to check and repair';
|
|
506
|
+
console.log(` heartbeat: ${heartbeatText}`);
|
|
133
507
|
if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
|
|
508
|
+
const reapTrouble = autoland.readState(root).last_reap_error;
|
|
509
|
+
if (reapTrouble) console.log(` cleanup trouble: landing sweep failed on ${reapTrouble.date} (${reapTrouble.error}) - run: atris land --reap`);
|
|
134
510
|
console.log('');
|
|
135
|
-
if (
|
|
136
|
-
console.log(` ready
|
|
137
|
-
for (const r of
|
|
511
|
+
if (readyForRecheck.length > 0) {
|
|
512
|
+
console.log(` ready for heartbeat recheck: ${readyForRecheck.length}`);
|
|
513
|
+
for (const r of readyForRecheck.slice(0, 10)) console.log(` rechecks then lands ${r.ref}`);
|
|
138
514
|
} else {
|
|
139
515
|
console.log(' nothing is ready to land on its own right now.');
|
|
140
516
|
}
|
|
141
517
|
const humanOnly = blocked.filter((r) => String(r.reason || '').startsWith('denied_tag_'));
|
|
142
518
|
const needsWork = blocked.filter((r) => !String(r.reason || '').startsWith('denied_tag_'));
|
|
143
519
|
if (humanOnly.length > 0) {
|
|
144
|
-
console.log(`
|
|
145
|
-
for (const r of humanOnly.slice(0, 10)) console.log(` waits for you ${r.ref}
|
|
520
|
+
console.log(` protected reviews waiting on you: ${humanOnly.length}`);
|
|
521
|
+
for (const r of humanOnly.slice(0, 10)) console.log(` waits for you ${r.ref} - ${plainReason(r.reason)}`);
|
|
146
522
|
}
|
|
147
523
|
if (needsWork.length > 0) {
|
|
148
524
|
console.log(` not ready yet: ${needsWork.length}`);
|
|
149
|
-
for (const r of needsWork.slice(0, 10)) console.log(` held back ${r.ref}
|
|
525
|
+
for (const r of needsWork.slice(0, 10)) console.log(` held back ${r.ref} - ${plainReason(String(r.reason || ''))}`);
|
|
150
526
|
}
|
|
151
527
|
if (waiting.length > 0) {
|
|
152
528
|
console.log('');
|
|
@@ -163,6 +539,8 @@ function turnOn(root, args) {
|
|
|
163
539
|
const to = flag(args, '--to', '');
|
|
164
540
|
const digestHour = Number(flag(args, '--digest-hour', autoland.DEFAULT_DIGEST_HOUR));
|
|
165
541
|
const alarmHours = Number(flag(args, '--alarm-hours', autoland.DEFAULT_ALARM_HOURS));
|
|
542
|
+
const acceptAll = args.includes('--all');
|
|
543
|
+
const previous = autoland.readPolicy(root);
|
|
166
544
|
const policy = autoland.writePolicy(root, {
|
|
167
545
|
enabled: true,
|
|
168
546
|
enabled_by: owner,
|
|
@@ -171,17 +549,25 @@ function turnOn(root, args) {
|
|
|
171
549
|
digest_hour: Number.isFinite(digestHour) ? digestHour : autoland.DEFAULT_DIGEST_HOUR,
|
|
172
550
|
alarm_hours: Number.isFinite(alarmHours) && alarmHours > 0 ? alarmHours : autoland.DEFAULT_ALARM_HOURS,
|
|
173
551
|
strict_verify: !args.includes('--no-strict-verify'),
|
|
552
|
+
accept_all: acceptAll,
|
|
553
|
+
daily_experiment: previous.daily_experiment !== false,
|
|
174
554
|
});
|
|
175
555
|
const cronOk = autoland.installCron(root);
|
|
556
|
+
autoland.writePolicy(root, {
|
|
557
|
+
...policy,
|
|
558
|
+
heartbeat_installed: cronOk,
|
|
559
|
+
heartbeat_checked_at: new Date().toISOString(),
|
|
560
|
+
});
|
|
176
561
|
console.log('');
|
|
177
562
|
console.log('autoland is on.');
|
|
178
|
-
console.log(`
|
|
563
|
+
if (acceptAll) console.log(` everything in review now lands itself, accepted as ${owner}; only the protected lanes wait.`);
|
|
564
|
+
else console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
|
|
179
565
|
console.log(' protected lanes (money, deploys, security, customer, outward) still wait for you.');
|
|
180
|
-
console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron
|
|
566
|
+
console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron; run atris autoland tick yourself'}`);
|
|
181
567
|
if (policy.imessage_to) {
|
|
182
568
|
console.log(` daily message to ${policy.imessage_to} at ${policy.digest_hour}:00; anything waiting on you past ${policy.alarm_hours}h pings you.`);
|
|
183
569
|
} else {
|
|
184
|
-
console.log(' no phone number set
|
|
570
|
+
console.log(' no phone number set; digest goes to the log only. add one: atris autoland on --to <your number>');
|
|
185
571
|
}
|
|
186
572
|
console.log(' turn it off any time: atris autoland off');
|
|
187
573
|
console.log('');
|
|
@@ -190,11 +576,20 @@ function turnOn(root, args) {
|
|
|
190
576
|
|
|
191
577
|
function turnOff(root) {
|
|
192
578
|
const policy = autoland.readPolicy(root) || {};
|
|
193
|
-
autoland.writePolicy(root, {
|
|
579
|
+
const disabledPolicy = autoland.writePolicy(root, {
|
|
580
|
+
...policy,
|
|
581
|
+
enabled: false,
|
|
582
|
+
disabled_at: new Date().toISOString(),
|
|
583
|
+
});
|
|
194
584
|
const cronOk = autoland.uninstallCron(root);
|
|
585
|
+
autoland.writePolicy(root, {
|
|
586
|
+
...disabledPolicy,
|
|
587
|
+
heartbeat_installed: cronOk ? false : null,
|
|
588
|
+
heartbeat_checked_at: new Date().toISOString(),
|
|
589
|
+
});
|
|
195
590
|
console.log('');
|
|
196
591
|
console.log('autoland is off. everything waits for your accept again.');
|
|
197
|
-
console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed
|
|
592
|
+
console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed; check crontab -l'}.`);
|
|
198
593
|
console.log('');
|
|
199
594
|
return 0;
|
|
200
595
|
}
|
|
@@ -202,32 +597,39 @@ function turnOff(root) {
|
|
|
202
597
|
function runDigest(root, args, { forceSend = false } = {}) {
|
|
203
598
|
const policy = autoland.readPolicy(root) || {};
|
|
204
599
|
const tasks = readProjection(root);
|
|
205
|
-
const
|
|
600
|
+
const acceptedTasks = readAcceptedTaskHistory(root, tasks);
|
|
601
|
+
const accepted = autoland.acceptedInLastDay(acceptedTasks);
|
|
602
|
+
const state = autoland.readState(root);
|
|
603
|
+
let waitingWishes = [];
|
|
604
|
+
try {
|
|
605
|
+
waitingWishes = require('./wish').waitingOperatorWishes(root);
|
|
606
|
+
} catch {}
|
|
607
|
+
const waiting = autoland.waitingOnHuman(tasks);
|
|
206
608
|
const text = autoland.composeDigest({
|
|
207
609
|
accepted,
|
|
208
|
-
waiting
|
|
610
|
+
waiting,
|
|
611
|
+
protectedWaiting: protectedReviewWaiting(root, policy, waiting),
|
|
612
|
+
waitingWishes,
|
|
209
613
|
landed: landSummarySafe(root),
|
|
210
614
|
project: projectName(root),
|
|
211
615
|
nextMoves: digestNextMoves(root),
|
|
616
|
+
acceptAll: Boolean(policy.accept_all),
|
|
617
|
+
reapError: state.last_reap_error?.error || null,
|
|
618
|
+
landingSweep: state.landing_sweep || null,
|
|
619
|
+
fullStory: true,
|
|
620
|
+
root,
|
|
212
621
|
});
|
|
213
622
|
console.log(text);
|
|
214
623
|
// the full story: what each piece actually was, in its own words
|
|
215
|
-
const
|
|
216
|
-
const storied = accepted.auto.filter((item) => {
|
|
217
|
-
const t = byRef.get(item.ref);
|
|
218
|
-
return t && (t.review?.landing?.happened || t.metadata?.landing_happened);
|
|
219
|
-
});
|
|
624
|
+
const storied = digestStoryRows(accepted, acceptedTasks).slice(3);
|
|
220
625
|
if (storied.length > 0) {
|
|
221
626
|
let printedStoryHeader = false;
|
|
222
|
-
for (const item of storied) {
|
|
223
|
-
const t = byRef.get(item.ref);
|
|
224
|
-
const happened = String(t.review?.landing?.happened || t.metadata?.landing_happened || '').replace(/\s+/g, ' ').slice(0, 160);
|
|
225
|
-
if (!operatorReady(happened)) continue;
|
|
627
|
+
for (const { item, story } of storied) {
|
|
226
628
|
if (!printedStoryHeader) {
|
|
227
629
|
console.log('');
|
|
228
630
|
printedStoryHeader = true;
|
|
229
631
|
}
|
|
230
|
-
console.log(` ${
|
|
632
|
+
console.log(` - ${story}`);
|
|
231
633
|
}
|
|
232
634
|
}
|
|
233
635
|
const shouldSend = (forceSend || args.includes('--send')) && policy.imessage_to;
|
|
@@ -239,38 +641,104 @@ function runDigest(root, args, { forceSend = false } = {}) {
|
|
|
239
641
|
return 0;
|
|
240
642
|
}
|
|
241
643
|
|
|
644
|
+
function pidAlive(pid) {
|
|
645
|
+
try {
|
|
646
|
+
process.kill(Number(pid), 0);
|
|
647
|
+
return true;
|
|
648
|
+
} catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
242
653
|
function runTick(root, args) {
|
|
243
654
|
const json = args.includes('--json');
|
|
244
655
|
const policy = autoland.readPolicy(root);
|
|
245
|
-
const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
|
|
656
|
+
const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_due: false, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
|
|
246
657
|
if (!policy || policy.enabled !== true) {
|
|
247
658
|
if (json) console.log(JSON.stringify(receipt));
|
|
248
659
|
else console.log('autoland is off; tick did nothing.');
|
|
249
660
|
return 0;
|
|
250
661
|
}
|
|
251
662
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
663
|
+
// One live tick at a time: verify re-runs can take minutes each, and a
|
|
664
|
+
// second hourly cron firing into the same snapshot double-accepts,
|
|
665
|
+
// double-alarms, and double-digests. A stale lock (dead pid or >55min)
|
|
666
|
+
// never wedges the loop.
|
|
667
|
+
const lockPath = path.join(root, '.atris', 'state', 'autoland.tick.lock');
|
|
668
|
+
try {
|
|
669
|
+
const prev = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
670
|
+
const fresh = Date.now() - Number(prev.at || 0) < 55 * 60 * 1000;
|
|
671
|
+
if (fresh && pidAlive(prev.pid)) {
|
|
672
|
+
receipt.skipped_reason = 'tick_already_running';
|
|
673
|
+
if (json) console.log(JSON.stringify(receipt));
|
|
674
|
+
else console.log('autoland tick: another tick is still running; skipped.');
|
|
675
|
+
return 0;
|
|
264
676
|
}
|
|
677
|
+
} catch {}
|
|
678
|
+
try {
|
|
679
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
680
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
681
|
+
} catch {}
|
|
682
|
+
try {
|
|
683
|
+
return runTickBody(root, { json, policy, receipt });
|
|
684
|
+
} finally {
|
|
685
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
265
686
|
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function persistTickReceipt(root, receipt) {
|
|
690
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
691
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
692
|
+
const safeTime = String(receipt.at || new Date().toISOString()).replace(/[:.]/g, '-');
|
|
693
|
+
const receiptPath = path.join(runsDir, `autoland-tick-${safeTime}.json`);
|
|
694
|
+
receipt.receipt_path = path.relative(root, receiptPath);
|
|
695
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
|
|
696
|
+
return receipt.receipt_path;
|
|
697
|
+
}
|
|
266
698
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
699
|
+
function digestTickStatus(receipt) {
|
|
700
|
+
if (receipt.digest_sent) return 'sent';
|
|
701
|
+
if (receipt.digest_due) return 'saved';
|
|
702
|
+
return 'not due';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function runTickBody(root, { json, policy, receipt }) {
|
|
706
|
+
|
|
707
|
+
// 1. certify and land in one task process. Keeping both phases together lets
|
|
708
|
+
// the landing gate reuse the live certification verifier result without
|
|
709
|
+
// persisting trust across heartbeats. Denied lanes and check-less proofs wait.
|
|
710
|
+
// No hardcoded --limit here: a fixed low cap (this used to be 12) silently
|
|
711
|
+
// undercounts a real backlog every single tick — 12/hour forever even with
|
|
712
|
+
// 78 certified rows waiting. Let `atris task auto-accept-certified` apply
|
|
713
|
+
// its own default (12 without --all, a high safety cap under --all) so a
|
|
714
|
+
// policy with accept_all:true actually drains the full certified set.
|
|
715
|
+
const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
|
|
716
|
+
if (policy.accept_all) cliArgs.push('--all');
|
|
717
|
+
else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
|
|
270
718
|
const accept = runOwnCli(root, cliArgs);
|
|
271
719
|
try {
|
|
272
720
|
const parsed = JSON.parse(accept.stdout);
|
|
273
|
-
|
|
721
|
+
const results = Array.isArray(parsed.results) ? parsed.results : [];
|
|
722
|
+
// A refused sweep (ok:false — a guard tripped, policy race) carries no
|
|
723
|
+
// summary fields. Name the reason instead of leaving nulls that read as
|
|
724
|
+
// "no work": a blind heartbeat must say WHY it is blind.
|
|
725
|
+
if (parsed.ok === false) {
|
|
726
|
+
receipt.accept_error = String(parsed.reason || 'auto-accept refused');
|
|
727
|
+
}
|
|
728
|
+
receipt.reviews_certified = parsed.certification?.certified ?? 0;
|
|
729
|
+
if (parsed.certification && parsed.certification.ok !== true) {
|
|
730
|
+
receipt.certify_error = 'certify-verified failed';
|
|
731
|
+
}
|
|
732
|
+
receipt.landed = results.filter((r) => r.action === 'accepted').map((r) => r.ref);
|
|
733
|
+
const citationBlocks = results
|
|
734
|
+
.filter((r) => r.action === 'skipped' && r.proof_state === 'suite_green_citation_required')
|
|
735
|
+
.map((r) => ({ ref: r.ref, reason: r.reason }));
|
|
736
|
+
if (citationBlocks.length) receipt.citation_blocks = citationBlocks;
|
|
737
|
+
receipt.certified = parsed.certified ?? null;
|
|
738
|
+
receipt.scanned = parsed.scanned ?? null;
|
|
739
|
+
receipt.revised = parsed.revised ?? null;
|
|
740
|
+
receipt.skipped = parsed.skipped ?? null;
|
|
741
|
+
receipt.undercounted = Boolean(parsed.undercounted);
|
|
274
742
|
} catch (err) {
|
|
275
743
|
receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
|
|
276
744
|
}
|
|
@@ -284,8 +752,12 @@ function runTick(root, args) {
|
|
|
284
752
|
landedRefs: receipt.landed,
|
|
285
753
|
tasks: tasksForLive,
|
|
286
754
|
project: projectName(root),
|
|
755
|
+
root,
|
|
287
756
|
});
|
|
288
757
|
if (text) {
|
|
758
|
+
// Keep the exact operator surface beside the delivery result. The next
|
|
759
|
+
// linguist pass must audit what was sent, not reconstruct today's code.
|
|
760
|
+
receipt.live_update_text = text;
|
|
289
761
|
const sent = autoland.sendImessage(root, policy.imessage_to, text);
|
|
290
762
|
receipt.live_update_sent = sent.ok;
|
|
291
763
|
}
|
|
@@ -295,6 +767,7 @@ function runTick(root, args) {
|
|
|
295
767
|
const state = autoland.readState(root);
|
|
296
768
|
const tasks = readProjection(root);
|
|
297
769
|
const waiting = autoland.waitingOnHuman(tasks);
|
|
770
|
+
const protectedWaiting = protectedReviewWaiting(root, policy, waiting);
|
|
298
771
|
const alarmHours = Number(policy.alarm_hours) || autoland.DEFAULT_ALARM_HOURS;
|
|
299
772
|
const due = autoland.dueForAlarm(waiting, state, { alarmHours });
|
|
300
773
|
if (due.length > 0 && policy.imessage_to) {
|
|
@@ -306,16 +779,98 @@ function runTick(root, args) {
|
|
|
306
779
|
}
|
|
307
780
|
}
|
|
308
781
|
|
|
782
|
+
// 3b. janitor, every tick: a mission left paused past 48h and a worktree
|
|
783
|
+
// already merged into base are the same rot pattern as an unlanded branch —
|
|
784
|
+
// left alone they accumulate until a human runs `mission stop` and
|
|
785
|
+
// `worktree cleanup --apply` by hand, which is exactly the chore autoland
|
|
786
|
+
// exists to remove. Off switch: policy.janitor === false (default on, the
|
|
787
|
+
// same absent-means-on convention as live_updates/strict_verify). Counts
|
|
788
|
+
// accumulate in state so the daily digest reports the day's total, not
|
|
789
|
+
// just the last tick's.
|
|
790
|
+
receipt.missions_stopped = 0;
|
|
791
|
+
receipt.missions_held = 0;
|
|
792
|
+
receipt.worktrees_reaped = 0;
|
|
793
|
+
if (policy.janitor !== false) {
|
|
794
|
+
let stoppedMissions = [];
|
|
795
|
+
let heldMissions = [];
|
|
796
|
+
try {
|
|
797
|
+
const { reapPausedMissions } = require('./mission');
|
|
798
|
+
stoppedMissions = reapPausedMissions(root);
|
|
799
|
+
heldMissions = Array.isArray(stoppedMissions.held) ? stoppedMissions.held : [];
|
|
800
|
+
receipt.missions_stopped = stoppedMissions.length;
|
|
801
|
+
if (stoppedMissions.length > 0) receipt.missions_stopped_refs = stoppedMissions.map((m) => m.id);
|
|
802
|
+
if (stoppedMissions.length > 0) receipt.missions_stopped_reasons = missionReasonBreakdown(stoppedMissions, 'stale');
|
|
803
|
+
receipt.missions_held = heldMissions.length;
|
|
804
|
+
if (heldMissions.length > 0) {
|
|
805
|
+
receipt.missions_held_refs = heldMissions.map((m) => m.id);
|
|
806
|
+
receipt.missions_held_reasons = missionReasonBreakdown(heldMissions, 'operator-required');
|
|
807
|
+
}
|
|
808
|
+
} catch (err) {
|
|
809
|
+
receipt.janitor_mission_error = String((err && err.message) || err).slice(0, 200);
|
|
810
|
+
}
|
|
811
|
+
try {
|
|
812
|
+
const { cleanupWorktrees } = require('./worktree');
|
|
813
|
+
const cleaned = cleanupWorktrees({ root, apply: true });
|
|
814
|
+
receipt.worktrees_reaped = cleaned.removed.length;
|
|
815
|
+
if (cleaned.removed.length > 0) receipt.worktrees_reaped_paths = cleaned.removed.map((w) => w.path);
|
|
816
|
+
} catch (err) {
|
|
817
|
+
receipt.janitor_worktree_error = String((err && err.message) || err).slice(0, 200);
|
|
818
|
+
}
|
|
819
|
+
recordJanitorState(state, { stopped: stoppedMissions, held: heldMissions, worktrees: receipt.worktrees_reaped });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// 3c. landing sweep, every tick: merged branch residue is cleared,
|
|
823
|
+
// TTL-expired work is salvaged before deletion, and 48h-stale active work
|
|
824
|
+
// is carried into the digest instead of silently aging forever.
|
|
825
|
+
try {
|
|
826
|
+
const landingSweep = sweepLanding(root);
|
|
827
|
+
receipt.reaped = landingSweep.reaped;
|
|
828
|
+
receipt.landing_sweep = {
|
|
829
|
+
before: landingSweep.before,
|
|
830
|
+
after: landingSweep.after,
|
|
831
|
+
stale: landingSweep.stale.length,
|
|
832
|
+
human: landingSweep.human.length,
|
|
833
|
+
};
|
|
834
|
+
if (landingSweep.reaped.bundleError) {
|
|
835
|
+
receipt.reap_error = `backup failed, unlanded work kept in place: ${landingSweep.reaped.bundleError}`;
|
|
836
|
+
}
|
|
837
|
+
recordLandingSweepState(state, landingSweep);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
receipt.reap_error = String((err && err.message) || err).slice(0, 200);
|
|
840
|
+
recordLandingSweepState(state, null, { error: receipt.reap_error });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 3d. wish sweep: queued wishes are allowed to wake a mission without a
|
|
844
|
+
// foreground `atris wish` session, but they must never endanger landing.
|
|
845
|
+
try {
|
|
846
|
+
const wishDispatch = require('./wish').sweepWishes(root);
|
|
847
|
+
receipt.wish_dispatch = wishDispatch;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
receipt.wish_dispatch_error = String((err && err.message) || err).slice(0, 200);
|
|
850
|
+
}
|
|
851
|
+
|
|
309
852
|
// 4. daily digest at the configured hour
|
|
310
853
|
const today = new Date().toISOString().slice(0, 10);
|
|
311
854
|
const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
|
|
312
855
|
if (new Date().getHours() === digestHour && state.last_digest_date !== today) {
|
|
856
|
+
receipt.digest_due = true;
|
|
857
|
+
let waitingWishes = [];
|
|
858
|
+
try {
|
|
859
|
+
waitingWishes = require('./wish').waitingOperatorWishes(root);
|
|
860
|
+
} catch {}
|
|
313
861
|
const text = autoland.composeDigest({
|
|
314
|
-
accepted: autoland.acceptedInLastDay(tasks),
|
|
862
|
+
accepted: autoland.acceptedInLastDay(readAcceptedTaskHistory(root, tasks)),
|
|
315
863
|
waiting,
|
|
864
|
+
protectedWaiting,
|
|
865
|
+
waitingWishes,
|
|
316
866
|
landed: landSummarySafe(root),
|
|
317
867
|
project: projectName(root),
|
|
318
868
|
nextMoves: digestNextMoves(root),
|
|
869
|
+
acceptAll: Boolean(policy.accept_all),
|
|
870
|
+
reapError: state.last_reap_error?.error || null,
|
|
871
|
+
janitor: state.janitor || null,
|
|
872
|
+
landingSweep: state.landing_sweep || null,
|
|
873
|
+
root,
|
|
319
874
|
});
|
|
320
875
|
if (policy.imessage_to) {
|
|
321
876
|
const sent = autoland.sendImessage(root, policy.imessage_to, text);
|
|
@@ -323,6 +878,7 @@ function runTick(root, args) {
|
|
|
323
878
|
}
|
|
324
879
|
receipt.digest_text = text;
|
|
325
880
|
state.last_digest_date = today;
|
|
881
|
+
delete state.janitor;
|
|
326
882
|
|
|
327
883
|
// 5. once a day, keep the receipt shelf lean: compress old run receipts
|
|
328
884
|
// into the manifest and drop unreferenced clutter, newest 200 kept.
|
|
@@ -334,27 +890,101 @@ function runTick(root, args) {
|
|
|
334
890
|
receipt.receipts_pruned = null;
|
|
335
891
|
}
|
|
336
892
|
}
|
|
893
|
+
|
|
894
|
+
// 5b. daily keep/revert experiment — self-gated inside experiments daily;
|
|
895
|
+
// hourly ticks are harmless no-ops after the first run each day.
|
|
896
|
+
if (policy.daily_experiment !== false) {
|
|
897
|
+
const daily = runOwnCli(root, ['experiments', 'daily', '--json']);
|
|
898
|
+
receipt.daily_experiment = {
|
|
899
|
+
status: daily.status,
|
|
900
|
+
stdout: daily.stdout.trim(),
|
|
901
|
+
stderr: daily.stderr.slice(0, 200) || null,
|
|
902
|
+
};
|
|
903
|
+
try {
|
|
904
|
+
receipt.daily_experiment.result = JSON.parse(daily.stdout.trim());
|
|
905
|
+
} catch {
|
|
906
|
+
receipt.daily_experiment.result = null;
|
|
907
|
+
}
|
|
908
|
+
} else {
|
|
909
|
+
receipt.daily_experiment_skipped = true;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 6. once a day, do slower mission cleanup. Landing cleanup runs hourly
|
|
913
|
+
// above, outside this date gate, so limbo cannot silently accumulate.
|
|
914
|
+
if (state.last_mission_cleanup_date !== today) {
|
|
915
|
+
// Missions rot the same way branches do: paused/planning/ready and
|
|
916
|
+
// untouched for a week means abandoned. Age them out under the same
|
|
917
|
+
// daily gate so `mission list` shows work, not archaeology.
|
|
918
|
+
try {
|
|
919
|
+
const { expireStaleMissions } = require('./mission');
|
|
920
|
+
const expiredMissions = expireStaleMissions(root);
|
|
921
|
+
const heldMissions = Array.isArray(expiredMissions.held) ? expiredMissions.held : [];
|
|
922
|
+
if (expiredMissions.length > 0) receipt.expired_missions = expiredMissions.length;
|
|
923
|
+
if (expiredMissions.length > 0) receipt.expired_mission_reasons = missionReasonBreakdown(expiredMissions, 'stale');
|
|
924
|
+
if (expiredMissions.length > 0) recordJanitorState(state, { stopped: expiredMissions });
|
|
925
|
+
if (heldMissions.length > 0) {
|
|
926
|
+
receipt.expired_missions_held = heldMissions.length;
|
|
927
|
+
receipt.expired_missions_held_refs = heldMissions.map((m) => m.id);
|
|
928
|
+
receipt.expired_missions_held_reasons = missionReasonBreakdown(heldMissions, 'operator-required');
|
|
929
|
+
recordJanitorState(state, { held: heldMissions });
|
|
930
|
+
}
|
|
931
|
+
} catch (err) {
|
|
932
|
+
receipt.mission_expiry_error = String((err && err.message) || err).slice(0, 200);
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
const missionVerify = verifyClosedTaskMissions(root, readProjection(root));
|
|
936
|
+
if (missionVerify.verified.length > 0) receipt.verified_missions = missionVerify.verified;
|
|
937
|
+
if (missionVerify.errors.length > 0) receipt.mission_verify_errors = missionVerify.errors;
|
|
938
|
+
} catch (err) {
|
|
939
|
+
receipt.mission_verify_errors = [{ error: String((err && err.message) || err).slice(0, 200) }];
|
|
940
|
+
}
|
|
941
|
+
state.last_mission_cleanup_date = today;
|
|
942
|
+
}
|
|
337
943
|
autoland.writeState(root, state);
|
|
338
944
|
|
|
945
|
+
try {
|
|
946
|
+
persistTickReceipt(root, receipt);
|
|
947
|
+
} catch (err) {
|
|
948
|
+
receipt.receipt_error = String((err && err.message) || err).slice(0, 200);
|
|
949
|
+
}
|
|
950
|
+
|
|
339
951
|
if (json) console.log(JSON.stringify(receipt));
|
|
340
952
|
else {
|
|
341
|
-
|
|
953
|
+
const reapedTotal = Number(receipt.reaped?.branches || 0) + Number(receipt.reaped?.worktrees || 0);
|
|
954
|
+
const reapNote = reapedTotal > 0 ? `, reaped ${receipt.reaped.branches} landed/overdue branches` : '';
|
|
955
|
+
const janitorNote = `, janitor stopped ${receipt.missions_stopped} mission${receipt.missions_stopped === 1 ? '' : 's'} + reaped ${receipt.worktrees_reaped} worktree${receipt.worktrees_reaped === 1 ? '' : 's'}`;
|
|
956
|
+
const heldNote = receipt.missions_held ? `, held ${receipt.missions_held} human-blocked mission${receipt.missions_held === 1 ? '' : 's'}` : '';
|
|
957
|
+
const wishNote = `, ${wishSweepSummaryLine(receipt.wish_dispatch, receipt.wish_dispatch_error)}`;
|
|
958
|
+
const receiptNote = receipt.receipt_path ? `, receipt ${receipt.receipt_path}` : '';
|
|
959
|
+
const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${receiptNote}`;
|
|
960
|
+
console.log(gateForHuman(summary).text);
|
|
961
|
+
for (const fulfilled of receipt.wish_dispatch?.fulfilled_results || []) {
|
|
962
|
+
if (fulfilled.review_ask) console.log(fulfilled.review_ask);
|
|
963
|
+
}
|
|
342
964
|
}
|
|
343
965
|
return 0;
|
|
344
966
|
}
|
|
345
967
|
|
|
346
968
|
function showHelp() {
|
|
347
969
|
console.log('');
|
|
348
|
-
console.log('atris autoland
|
|
970
|
+
console.log('atris autoland: you approve the policy once; certified work lands itself');
|
|
349
971
|
console.log('');
|
|
350
972
|
console.log('finished work that passed its checks and two independent reviews lands');
|
|
351
973
|
console.log('automatically with a receipt. money, deploys, security, customer, and');
|
|
352
974
|
console.log('outward-facing work always waits for you.');
|
|
353
975
|
console.log('');
|
|
976
|
+
console.log('recommended agent flight checks:');
|
|
977
|
+
console.log(' fast suite: npm run test:fast');
|
|
978
|
+
console.log(' focused: node --test <focused files>');
|
|
979
|
+
console.log('record one runnable verifier; autoland reruns the recorded check.');
|
|
980
|
+
console.log('');
|
|
354
981
|
console.log(' atris autoland what would land, what waits for you');
|
|
355
982
|
console.log(' atris autoland on [--to <phone>] flip it on: hourly heartbeat, daily');
|
|
356
983
|
console.log(' message, ping when something waits');
|
|
357
984
|
console.log(' on you past 24h');
|
|
985
|
+
console.log(' atris autoland on --all lower the bar: everything lands except');
|
|
986
|
+
console.log(' the protected lanes; a failing recorded');
|
|
987
|
+
console.log(' check still blocks');
|
|
358
988
|
console.log(' atris autoland off back to approving every item');
|
|
359
989
|
console.log(' atris autoland digest [--send] the daily message, now');
|
|
360
990
|
console.log(' atris autoland tick [--json] one heartbeat (what the hourly cron runs)');
|
|
@@ -382,4 +1012,20 @@ function autolandCommand(args = []) {
|
|
|
382
1012
|
return 1;
|
|
383
1013
|
}
|
|
384
1014
|
|
|
385
|
-
module.exports = {
|
|
1015
|
+
module.exports = {
|
|
1016
|
+
autolandCommand,
|
|
1017
|
+
attachedTasksForMission,
|
|
1018
|
+
digestNextMoves,
|
|
1019
|
+
digestStoryRows,
|
|
1020
|
+
digestTickStatus,
|
|
1021
|
+
explainResult,
|
|
1022
|
+
missionReadyForClosedTaskVerify,
|
|
1023
|
+
operatorReady,
|
|
1024
|
+
readAcceptedTaskHistory,
|
|
1025
|
+
hasAgentJargon,
|
|
1026
|
+
runTickBody,
|
|
1027
|
+
persistTickReceipt,
|
|
1028
|
+
sweepLanding,
|
|
1029
|
+
verifyClosedTaskMissions,
|
|
1030
|
+
wishSweepSummaryLine,
|
|
1031
|
+
};
|