atris 3.35.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 +37 -0
- 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 +22 -4
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +2 -0
- package/ax +147 -19
- package/bin/atris.js +565 -265
- package/commands/activate.js +194 -88
- package/commands/agents.js +166 -0
- package/commands/autoland.js +459 -107
- 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/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- 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/engine.js +993 -32
- package/commands/experiments.js +28 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/gm.js +23 -0
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +72 -44
- package/commands/interview.js +67 -1
- package/commands/land.js +152 -52
- package/commands/lifecycle.js +39 -3
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +511 -34
- package/commands/mission.js +3029 -339
- package/commands/next.js +137 -0
- package/commands/now.js +220 -25
- 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/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/stream.js +861 -0
- package/commands/study.js +693 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +1346 -117
- package/commands/team.js +73 -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 +234 -13
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +331 -34
- package/lib/autoland.js +319 -54
- 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 +2219 -67
- 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/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/next-moves.js +327 -10
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -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/router-brain.js +352 -0
- package/lib/runner-command.js +10 -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 +108 -29
- package/lib/task-proof.js +23 -1
- 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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_DAYS = 7;
|
|
8
|
+
|
|
9
|
+
function emptyOrbScorecard(days = DEFAULT_DAYS) {
|
|
10
|
+
return {
|
|
11
|
+
days,
|
|
12
|
+
picks: 0,
|
|
13
|
+
dispatches: 0,
|
|
14
|
+
dispatches_by_kind: {},
|
|
15
|
+
dispatches_by_engine: {},
|
|
16
|
+
ok: 0,
|
|
17
|
+
fail: 0,
|
|
18
|
+
orphaned: 0,
|
|
19
|
+
completion_rate: null,
|
|
20
|
+
median_duration_ms: null,
|
|
21
|
+
failures: [],
|
|
22
|
+
orphans: [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readText(file) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readFileSync(file, 'utf8');
|
|
29
|
+
} catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJsonl(file) {
|
|
35
|
+
const rows = [];
|
|
36
|
+
for (const line of readText(file).split(/\r?\n/)) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) continue;
|
|
39
|
+
try {
|
|
40
|
+
const row = JSON.parse(trimmed);
|
|
41
|
+
if (row && typeof row === 'object') rows.push(row);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sortedCounts(values) {
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
const key = typeof value === 'string' && value.trim() ? value.trim() : 'unknown';
|
|
51
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return Object.fromEntries([...counts.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function median(values) {
|
|
57
|
+
const numbers = values.filter((value) => Number.isFinite(value) && value >= 0).sort((a, b) => a - b);
|
|
58
|
+
if (!numbers.length) return null;
|
|
59
|
+
const middle = Math.floor(numbers.length / 2);
|
|
60
|
+
return numbers.length % 2 === 1
|
|
61
|
+
? numbers[middle]
|
|
62
|
+
: (numbers[middle - 1] + numbers[middle]) / 2;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readOrbPicks(root, cutoffDay, today) {
|
|
66
|
+
let picks = 0;
|
|
67
|
+
for (const line of readText(path.join(root, 'now.md')).split(/\r?\n/)) {
|
|
68
|
+
const match = line.match(/^orb:\s+.+\s+·\s+(\d{4}-\d{2}-\d{2})\s*$/);
|
|
69
|
+
if (match && match[1] >= cutoffDay && match[1] <= today) picks += 1;
|
|
70
|
+
}
|
|
71
|
+
return picks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pidIsAlive(pid) {
|
|
75
|
+
const numericPid = Number(pid);
|
|
76
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(numericPid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pairOrbRuns(records) {
|
|
86
|
+
const terminalsByLogPath = new Map();
|
|
87
|
+
for (const row of records) {
|
|
88
|
+
if (row.status === 'dispatched') continue;
|
|
89
|
+
const logPath = String(row.logPath || '');
|
|
90
|
+
const terminals = terminalsByLogPath.get(logPath) || [];
|
|
91
|
+
terminals.push(row);
|
|
92
|
+
terminalsByLogPath.set(logPath, terminals);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const consumedTerminals = new Set();
|
|
96
|
+
const runs = [];
|
|
97
|
+
const orphans = [];
|
|
98
|
+
for (const row of records) {
|
|
99
|
+
if (row.status !== 'dispatched') continue;
|
|
100
|
+
const terminals = terminalsByLogPath.get(String(row.logPath || '')) || [];
|
|
101
|
+
const terminal = terminals.find((candidate) => !consumedTerminals.has(candidate));
|
|
102
|
+
if (terminal) {
|
|
103
|
+
consumedTerminals.add(terminal);
|
|
104
|
+
runs.push({ ...row, ...terminal, status: terminal.status });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const orphaned = !pidIsAlive(row.pid);
|
|
108
|
+
runs.push({ ...row, orphaned });
|
|
109
|
+
if (orphaned) orphans.push({ ...row, orphaned: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const row of records) {
|
|
113
|
+
if (row.status !== 'dispatched' && !consumedTerminals.has(row)) runs.push(row);
|
|
114
|
+
}
|
|
115
|
+
return { runs, orphans };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readOrbScorecard(root, { days = DEFAULT_DAYS, now = Date.now() } = {}) {
|
|
119
|
+
const windowDays = Number(days);
|
|
120
|
+
if (!Number.isInteger(windowDays) || windowDays < 1) {
|
|
121
|
+
throw new Error('orb scorecard days must be a positive integer');
|
|
122
|
+
}
|
|
123
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
124
|
+
if (!Number.isFinite(nowMs)) throw new Error('orb scorecard now must be a valid time');
|
|
125
|
+
|
|
126
|
+
const scorecard = emptyOrbScorecard(windowDays);
|
|
127
|
+
const cutoffMs = nowMs - (windowDays * DAY_MS);
|
|
128
|
+
const cutoffDay = new Date(cutoffMs).toISOString().slice(0, 10);
|
|
129
|
+
const today = new Date(nowMs).toISOString().slice(0, 10);
|
|
130
|
+
scorecard.picks = readOrbPicks(root, cutoffDay, today);
|
|
131
|
+
|
|
132
|
+
const indexPath = path.join(root, '.atris', 'state', 'orb-runs', 'index.jsonl');
|
|
133
|
+
const records = readJsonl(indexPath).filter((row) => {
|
|
134
|
+
const ts = Date.parse(String(row.ts || ''));
|
|
135
|
+
return Number.isFinite(ts) && ts >= cutoffMs && ts <= nowMs;
|
|
136
|
+
});
|
|
137
|
+
const paired = pairOrbRuns(records);
|
|
138
|
+
const runs = paired.runs;
|
|
139
|
+
const terminalRuns = runs.filter((row) => row.status !== 'dispatched');
|
|
140
|
+
|
|
141
|
+
scorecard.dispatches = runs.length;
|
|
142
|
+
scorecard.dispatches_by_kind = sortedCounts(runs.map((row) => row.kind));
|
|
143
|
+
scorecard.dispatches_by_engine = sortedCounts(runs.map((row) => row.engine));
|
|
144
|
+
scorecard.ok = terminalRuns.filter((row) => Number(row.exitCode) === 0).length;
|
|
145
|
+
scorecard.fail = terminalRuns.filter((row) => Number(row.exitCode) !== 0).length + paired.orphans.length;
|
|
146
|
+
scorecard.orphaned = paired.orphans.length;
|
|
147
|
+
const outcomes = scorecard.ok + scorecard.fail;
|
|
148
|
+
scorecard.completion_rate = outcomes ? scorecard.ok / outcomes : null;
|
|
149
|
+
scorecard.median_duration_ms = median(terminalRuns.map((row) => Number(row.durationMs)));
|
|
150
|
+
scorecard.failures = terminalRuns
|
|
151
|
+
.filter((row) => Number(row.exitCode) !== 0)
|
|
152
|
+
.map((row) => ({
|
|
153
|
+
ts: row.ts,
|
|
154
|
+
label: row.label,
|
|
155
|
+
kind: row.kind,
|
|
156
|
+
engine: row.engine,
|
|
157
|
+
exitCode: Number(row.exitCode),
|
|
158
|
+
durationMs: Number(row.durationMs),
|
|
159
|
+
logPath: row.logPath,
|
|
160
|
+
error: row.error || null,
|
|
161
|
+
}));
|
|
162
|
+
scorecard.orphans = paired.orphans.map((row) => ({
|
|
163
|
+
ts: row.ts,
|
|
164
|
+
label: row.label,
|
|
165
|
+
kind: row.kind,
|
|
166
|
+
engine: row.engine,
|
|
167
|
+
logPath: row.logPath,
|
|
168
|
+
pid: Number(row.pid),
|
|
169
|
+
status: row.status,
|
|
170
|
+
}));
|
|
171
|
+
return scorecard;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatCounts(counts) {
|
|
175
|
+
const entries = Object.entries(counts || {});
|
|
176
|
+
return entries.length ? entries.map(([key, count]) => `${key} ${count}`).join(', ') : 'none';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderOrbScorecard(scorecard) {
|
|
180
|
+
const rate = scorecard.completion_rate == null
|
|
181
|
+
? 'n/a'
|
|
182
|
+
: `${(scorecard.completion_rate * 100).toFixed(1)}%`;
|
|
183
|
+
const duration = scorecard.median_duration_ms == null
|
|
184
|
+
? 'n/a'
|
|
185
|
+
: `${scorecard.median_duration_ms} ms`;
|
|
186
|
+
return [
|
|
187
|
+
`orb scorecard: ${scorecard.days} days`,
|
|
188
|
+
`picks: ${scorecard.picks}`,
|
|
189
|
+
`dispatches: ${scorecard.dispatches}`,
|
|
190
|
+
`by kind: ${formatCounts(scorecard.dispatches_by_kind)}`,
|
|
191
|
+
`by engine: ${formatCounts(scorecard.dispatches_by_engine)}`,
|
|
192
|
+
`outcomes: ${scorecard.ok} ok, ${scorecard.fail} fail`,
|
|
193
|
+
`orphaned: ${scorecard.orphaned}`,
|
|
194
|
+
`completion rate: ${rate}`,
|
|
195
|
+
`median duration: ${duration}`,
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseOrbScorecardDays(args = []) {
|
|
200
|
+
let raw = String(DEFAULT_DAYS);
|
|
201
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
202
|
+
const arg = args[index];
|
|
203
|
+
if (arg === '--days') {
|
|
204
|
+
raw = args[index + 1];
|
|
205
|
+
index += 1;
|
|
206
|
+
} else if (String(arg).startsWith('--days=')) {
|
|
207
|
+
raw = String(arg).slice('--days='.length);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const days = Number(raw);
|
|
211
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
212
|
+
return { ok: false, error: `invalid --days value: ${raw == null ? '(missing)' : raw}` };
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, days };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
DAY_MS,
|
|
219
|
+
DEFAULT_DAYS,
|
|
220
|
+
emptyOrbScorecard,
|
|
221
|
+
readOrbScorecard,
|
|
222
|
+
renderOrbScorecard,
|
|
223
|
+
parseOrbScorecardDays,
|
|
224
|
+
};
|
package/lib/policy-lessons.js
CHANGED
|
@@ -16,6 +16,7 @@ const POLICY_LESSONS_FILE = path.join('.atris', 'state', 'policy_lessons.json');
|
|
|
16
16
|
const CAREER_XP_RECEIPTS_FILE = path.join('.atris', 'state', 'career_xp_receipts.jsonl');
|
|
17
17
|
const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
|
|
18
18
|
const SCORECARDS_FILE = path.join('.atris', 'state', 'scorecards.jsonl');
|
|
19
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
20
|
|
|
20
21
|
// Review actors that are agents, not the human gate. Mining must split the
|
|
21
22
|
// two: agent self-review churn and human accept/bounce are different signals.
|
|
@@ -93,6 +94,7 @@ function mineProofPolicy(history, opts = {}) {
|
|
|
93
94
|
const { receipts = [], episodes = [], scorecards = [] } = history || {};
|
|
94
95
|
const minHumanReviewed = Number.isFinite(opts.minHumanReviewed) ? opts.minHumanReviewed : 10;
|
|
95
96
|
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
97
|
+
const orbWindowDays = Number.isInteger(opts.orbWindowDays) && opts.orbWindowDays > 0 ? opts.orbWindowDays : 7;
|
|
96
98
|
|
|
97
99
|
const reviewed = episodes.filter((e) => e && e.rl && e.action && e.action.actor);
|
|
98
100
|
const humanReviewed = reviewed.filter((e) => !isAgentActor(e.action.actor));
|
|
@@ -117,6 +119,15 @@ function mineProofPolicy(history, opts = {}) {
|
|
|
117
119
|
|
|
118
120
|
const improveTicks = scorecards.filter((s) => s && s.schema === 'atris.improve_tick.v1');
|
|
119
121
|
const brainScorecards = scorecards.filter((s) => s && s.schema === 'atris.brain.scorecard.v1');
|
|
122
|
+
const orbFailures = scorecards.filter((s) => s
|
|
123
|
+
&& s.schema === 'atris.improve_tick.v1'
|
|
124
|
+
&& s.source === 'orb'
|
|
125
|
+
&& Number(s.orb_exit_code) !== 0);
|
|
126
|
+
const orbCutoff = now.getTime() - (orbWindowDays * DAY_MS);
|
|
127
|
+
const recentOrbFailures = orbFailures.filter((row) => {
|
|
128
|
+
const ts = Date.parse(String(row.ts || ''));
|
|
129
|
+
return Number.isFinite(ts) && ts >= orbCutoff && ts <= now.getTime();
|
|
130
|
+
});
|
|
120
131
|
const avgReward = (rows) => (rows.length
|
|
121
132
|
? Math.round((rows.reduce((sum, r) => sum + (Number(r.reward) || 0), 0) / rows.length) * 100) / 100
|
|
122
133
|
: null);
|
|
@@ -139,6 +150,11 @@ function mineProofPolicy(history, opts = {}) {
|
|
|
139
150
|
total: scorecards.length,
|
|
140
151
|
improve_ticks: { count: improveTicks.length, avg_reward: avgReward(improveTicks) },
|
|
141
152
|
brain: { count: brainScorecards.length, avg_reward: avgReward(brainScorecards) },
|
|
153
|
+
orb: {
|
|
154
|
+
failures: orbFailures.length,
|
|
155
|
+
recent_failures: recentOrbFailures.length,
|
|
156
|
+
window_days: orbWindowDays,
|
|
157
|
+
},
|
|
142
158
|
},
|
|
143
159
|
};
|
|
144
160
|
|
|
@@ -173,6 +189,41 @@ function mineProofPolicy(history, opts = {}) {
|
|
|
173
189
|
});
|
|
174
190
|
}
|
|
175
191
|
|
|
192
|
+
if (recentOrbFailures.length > 0) {
|
|
193
|
+
const samples = recentOrbFailures.slice(0, 3).map((row) => {
|
|
194
|
+
const label = String(row.what_shipped || '').replace(/^orb job failed:\s*/i, '') || 'unlabelled job';
|
|
195
|
+
const logPath = row.orb_log_path || '.atris/state/orb-runs/index.jsonl';
|
|
196
|
+
return `${label} (${row.orb_engine || 'unknown'} exit ${row.orb_exit_code}, \`${logPath}\`)`;
|
|
197
|
+
});
|
|
198
|
+
const extra = recentOrbFailures.length > samples.length ? `, plus ${recentOrbFailures.length - samples.length} more` : '';
|
|
199
|
+
lessons.push({
|
|
200
|
+
id: 'orb-job-failures',
|
|
201
|
+
status: 'fail',
|
|
202
|
+
hint_when: null,
|
|
203
|
+
lesson: `${recentOrbFailures.length} orb job failure${recentOrbFailures.length === 1 ? '' : 's'} landed in the last ${orbWindowDays} days: ${samples.join('; ')}${extra}. Treat each log as an improvement candidate: inspect it, repair \`commands/orb.js\` dispatch or the picked job, then rerun the pick.`,
|
|
204
|
+
evidence: {
|
|
205
|
+
source: 'scorecards.orb_failure',
|
|
206
|
+
window_days: orbWindowDays,
|
|
207
|
+
failures: recentOrbFailures.map((row) => ({
|
|
208
|
+
ts: row.ts,
|
|
209
|
+
label: String(row.what_shipped || '').replace(/^orb job failed:\s*/i, ''),
|
|
210
|
+
engine: row.orb_engine,
|
|
211
|
+
exit_code: row.orb_exit_code,
|
|
212
|
+
log_path: row.orb_log_path,
|
|
213
|
+
next_task: row.next_task_suggestion,
|
|
214
|
+
})),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
} else if (orbFailures.length > 0) {
|
|
218
|
+
lessons.push({
|
|
219
|
+
id: 'orb-job-failures',
|
|
220
|
+
status: 'pass',
|
|
221
|
+
hint_when: null,
|
|
222
|
+
lesson: `No orb job failures remain in the last ${orbWindowDays} days; the prior failure window aged out.`,
|
|
223
|
+
evidence: { source: 'scorecards.orb_failure', window_days: orbWindowDays, failures: [] },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
176
227
|
return {
|
|
177
228
|
schema: 'atris.policy_lessons.v1',
|
|
178
229
|
mined_at: now.toISOString(),
|
|
@@ -243,7 +294,7 @@ function syncLessonsMd(root, mined) {
|
|
|
243
294
|
const today = (mined.mined_at || new Date().toISOString()).split('T')[0];
|
|
244
295
|
const lines = (mined.lessons || []).map((lesson) => ({
|
|
245
296
|
id: lesson.id,
|
|
246
|
-
line: `- **[${today}] policy-${lesson.id}** — pass — ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
|
|
297
|
+
line: `- **[${today}] policy-${lesson.id}** — ${lesson.status || 'pass'} — ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
|
|
247
298
|
}));
|
|
248
299
|
if (!lines.length) return { path: lessonsPath, written: [] };
|
|
249
300
|
|
package/lib/pulse.js
CHANGED
|
@@ -13,14 +13,18 @@
|
|
|
13
13
|
// command (commands/pulse.js) wires it to the engine and the cron shell.
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const os = require('os');
|
|
16
18
|
const path = require('path');
|
|
17
19
|
const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
|
|
20
|
+
const { readOrbScorecard } = require('./orb-scorecard');
|
|
18
21
|
|
|
19
22
|
const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
|
|
20
23
|
// Reuse the improve-tick scorecard schema so the brain + policy-lessons see
|
|
21
24
|
// pulse reward as fresh feedback signal (source:'pulse' keeps it attributable).
|
|
22
25
|
const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
|
|
23
26
|
const PULSE_MARKER = 'ATRIS_PULSE_SELF_IMPROVE';
|
|
27
|
+
const LEGACY_STATE_DIRNAME = 'atris-cli-self-improve';
|
|
24
28
|
// Hourly at an off-clock minute (avoid :00/:30 fleet sync). Each tick spawns a
|
|
25
29
|
// real worker + full verify, so default conservative; raise with --cadence.
|
|
26
30
|
const DEFAULT_CADENCE_CRON = '23 * * * *';
|
|
@@ -47,6 +51,77 @@ function pulseLockDir(root) {
|
|
|
47
51
|
return path.join(stateDir(root), 'pulse.lock');
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
// --- install slot identity ---
|
|
55
|
+
|
|
56
|
+
function safeSlugPart(value) {
|
|
57
|
+
const cleaned = String(value || '')
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
60
|
+
.replace(/-+/g, '-')
|
|
61
|
+
.replace(/^-+|-+$/g, '');
|
|
62
|
+
return cleaned || 'repo';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pulseRepoSlug(root) {
|
|
66
|
+
const absoluteRoot = path.resolve(root || process.cwd());
|
|
67
|
+
const base = safeSlugPart(path.basename(absoluteRoot));
|
|
68
|
+
const hash = crypto.createHash('sha256').update(absoluteRoot).digest('hex').slice(0, 6);
|
|
69
|
+
return `${base}-${hash}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pulseStateHome(root, homeDir = os.homedir()) {
|
|
73
|
+
return path.join(homeDir, '.atris', 'overnight', `pulse-${pulseRepoSlug(root)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pulseMarker(root) {
|
|
77
|
+
return `ATRIS_PULSE_${pulseRepoSlug(root).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function legacyPulseStateHome(homeDir = os.homedir()) {
|
|
81
|
+
return path.join(homeDir, '.atris', 'overnight', LEGACY_STATE_DIRNAME);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readTickScriptRoot(stateHome) {
|
|
85
|
+
try {
|
|
86
|
+
const script = fs.readFileSync(path.join(stateHome, 'tick.sh'), 'utf8');
|
|
87
|
+
const match = script.match(/^ROOT=(?:"([^"\n]*)"|'([^'\n]*)'|([^\s\n]+))/m);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
return match[1] || match[2] || match[3] || null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function legacyPulseStateMatchesRoot(root, homeDir = os.homedir()) {
|
|
96
|
+
const legacyStateHome = legacyPulseStateHome(homeDir);
|
|
97
|
+
const scriptRoot = readTickScriptRoot(legacyStateHome);
|
|
98
|
+
if (!scriptRoot) return false;
|
|
99
|
+
return path.resolve(scriptRoot) === path.resolve(root || process.cwd());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolvePulseSlot(root, options = {}) {
|
|
103
|
+
const homeDir = options.homeDir || os.homedir();
|
|
104
|
+
const stateHome = pulseStateHome(root, homeDir);
|
|
105
|
+
const marker = pulseMarker(root);
|
|
106
|
+
const legacyStateHome = legacyPulseStateHome(homeDir);
|
|
107
|
+
const legacyMatches = legacyPulseStateMatchesRoot(root, homeDir);
|
|
108
|
+
const hasStateHome = fs.existsSync(stateHome);
|
|
109
|
+
const activeStateHome = legacyMatches && !hasStateHome ? legacyStateHome : stateHome;
|
|
110
|
+
const activeMarker = legacyMatches && !hasStateHome ? PULSE_MARKER : marker;
|
|
111
|
+
const markers = legacyMatches ? [marker, PULSE_MARKER] : [marker];
|
|
112
|
+
return {
|
|
113
|
+
slug: pulseRepoSlug(root),
|
|
114
|
+
stateHome,
|
|
115
|
+
marker,
|
|
116
|
+
activeStateHome,
|
|
117
|
+
activeMarker,
|
|
118
|
+
legacyStateHome,
|
|
119
|
+
legacyMarker: PULSE_MARKER,
|
|
120
|
+
legacyMatches,
|
|
121
|
+
markers,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
50
125
|
// --- receipt + scorecard building (pure) ---
|
|
51
126
|
|
|
52
127
|
function buildPulseReceipt(input = {}) {
|
|
@@ -65,6 +140,9 @@ function buildPulseReceipt(input = {}) {
|
|
|
65
140
|
elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
|
|
66
141
|
prev_tick_stale: input.prevTickStale != null ? input.prevTickStale : false,
|
|
67
142
|
reward: input.reward != null ? input.reward : null,
|
|
143
|
+
orb_scorecard: input.orbScorecard || null,
|
|
144
|
+
improvement_candidates: Array.isArray(input.improvementCandidates) ? input.improvementCandidates : [],
|
|
145
|
+
orb_ingest_error: input.orbIngestError || null,
|
|
68
146
|
};
|
|
69
147
|
}
|
|
70
148
|
|
|
@@ -83,6 +161,98 @@ function buildPulseScorecardRow(input = {}) {
|
|
|
83
161
|
model_used: input.model || null,
|
|
84
162
|
task_id: input.taskId || null,
|
|
85
163
|
elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
|
|
164
|
+
orb_scorecard: input.orbScorecard || null,
|
|
165
|
+
improvement_candidates: Array.isArray(input.improvementCandidates) ? input.improvementCandidates : [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function orbRunKey(run = {}) {
|
|
170
|
+
return [run.ts, run.logPath, run.engine, run.label].map((value) => String(value || '')).join('|');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function orbFailureCandidate(run = {}, mode = 'orb_failure') {
|
|
174
|
+
const label = String(run.label || 'unlabelled orb job');
|
|
175
|
+
const engine = String(run.engine || 'unknown engine');
|
|
176
|
+
const exitCode = Number.isFinite(Number(run.exitCode)) ? Number(run.exitCode) : 1;
|
|
177
|
+
const logPath = String(run.logPath || '.atris/state/orb-runs/index.jsonl');
|
|
178
|
+
const orphaned = mode === 'orb_orphan';
|
|
179
|
+
return {
|
|
180
|
+
source: 'orb',
|
|
181
|
+
label,
|
|
182
|
+
engine,
|
|
183
|
+
exit_code: exitCode,
|
|
184
|
+
log_path: logPath,
|
|
185
|
+
next_task: orphaned
|
|
186
|
+
? `investigate orphaned orb job "${label}" from ${engine}; open ${logPath}`
|
|
187
|
+
: `investigate failed orb job "${label}" from ${engine} exit ${exitCode}; open ${logPath}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildOrbFailureScorecardRow(run = {}, mode = 'orb_failure') {
|
|
192
|
+
const candidate = orbFailureCandidate(run, mode);
|
|
193
|
+
const orphaned = mode === 'orb_orphan';
|
|
194
|
+
const parsedTs = Date.parse(String(run.ts || ''));
|
|
195
|
+
return {
|
|
196
|
+
schema: SCORECARD_SCHEMA,
|
|
197
|
+
ts: Number.isFinite(parsedTs) ? new Date(parsedTs).toISOString() : new Date().toISOString(),
|
|
198
|
+
source: 'orb',
|
|
199
|
+
member: 'pulse',
|
|
200
|
+
mode,
|
|
201
|
+
reward: -1,
|
|
202
|
+
verify_passed: false,
|
|
203
|
+
credits_deducted: 0,
|
|
204
|
+
what_shipped: `orb job ${orphaned ? 'orphaned' : 'failed'}: ${candidate.label}`,
|
|
205
|
+
files_written: [],
|
|
206
|
+
model_used: candidate.engine,
|
|
207
|
+
task_id: null,
|
|
208
|
+
elapsed_ms: Number.isFinite(Number(run.durationMs)) ? Number(run.durationMs) : null,
|
|
209
|
+
next_task_suggestion: candidate.next_task,
|
|
210
|
+
orb_run_key: orbRunKey(run),
|
|
211
|
+
orb_kind: run.kind || 'unknown',
|
|
212
|
+
orb_engine: candidate.engine,
|
|
213
|
+
orb_exit_code: candidate.exit_code,
|
|
214
|
+
orb_log_path: candidate.log_path,
|
|
215
|
+
orb_error: run.error || null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function orbSummaryForRail(scorecard) {
|
|
220
|
+
return {
|
|
221
|
+
days: scorecard.days,
|
|
222
|
+
picks: scorecard.picks,
|
|
223
|
+
dispatches: scorecard.dispatches,
|
|
224
|
+
dispatches_by_kind: scorecard.dispatches_by_kind,
|
|
225
|
+
dispatches_by_engine: scorecard.dispatches_by_engine,
|
|
226
|
+
ok: scorecard.ok,
|
|
227
|
+
fail: scorecard.fail,
|
|
228
|
+
orphaned: scorecard.orphaned,
|
|
229
|
+
completion_rate: scorecard.completion_rate,
|
|
230
|
+
median_duration_ms: scorecard.median_duration_ms,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ingestOrbScorecard(root, options = {}) {
|
|
235
|
+
const scorecard = readOrbScorecard(root, options);
|
|
236
|
+
const existing = readJsonl(scorecardsPath(root));
|
|
237
|
+
const seen = new Set(existing.filter((row) => row && row.orb_run_key).map((row) => row.orb_run_key));
|
|
238
|
+
const written = [];
|
|
239
|
+
const actionableRuns = [
|
|
240
|
+
...scorecard.failures.map((run) => ({ run, mode: 'orb_failure' })),
|
|
241
|
+
...scorecard.orphans.map((run) => ({ run, mode: 'orb_orphan' })),
|
|
242
|
+
];
|
|
243
|
+
for (const { run, mode } of actionableRuns) {
|
|
244
|
+
const row = buildOrbFailureScorecardRow(run, mode);
|
|
245
|
+
if (seen.has(row.orb_run_key)) continue;
|
|
246
|
+
appendScorecard(root, row);
|
|
247
|
+
seen.add(row.orb_run_key);
|
|
248
|
+
written.push(row);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
summary: orbSummaryForRail(scorecard),
|
|
252
|
+
improvement_candidates: actionableRuns.map(({ run, mode }) => orbFailureCandidate(run, mode)),
|
|
253
|
+
scorecards_written: written.length,
|
|
254
|
+
written,
|
|
255
|
+
historical_failures: existing.filter((row) => row && row.source === 'orb' && ['orb_failure', 'orb_orphan'].includes(row.mode)).length + written.length,
|
|
86
256
|
};
|
|
87
257
|
}
|
|
88
258
|
|
|
@@ -130,6 +300,68 @@ function shouldWriteScorecard({ reward } = {}) {
|
|
|
130
300
|
return reward !== 0;
|
|
131
301
|
}
|
|
132
302
|
|
|
303
|
+
// --- non-git work detection ---
|
|
304
|
+
//
|
|
305
|
+
// A workspace without .git made producedWork permanently false (git snapshots
|
|
306
|
+
// return an empty delta), so every tick scored 0 regardless of what the engine
|
|
307
|
+
// wrote — the loop was blind, not idle. When git is absent, pulse falls back to
|
|
308
|
+
// a filesystem snapshot: path → mtime+size, excluding churn dirs.
|
|
309
|
+
|
|
310
|
+
const FS_SNAPSHOT_SKIP = new Set(['.git', 'node_modules', '.atris']);
|
|
311
|
+
const FS_SNAPSHOT_MAX_ENTRIES = 20000;
|
|
312
|
+
|
|
313
|
+
function fsSnapshot(root) {
|
|
314
|
+
const entries = new Map();
|
|
315
|
+
const walk = (dir, rel) => {
|
|
316
|
+
if (entries.size >= FS_SNAPSHOT_MAX_ENTRIES) return;
|
|
317
|
+
let names;
|
|
318
|
+
try {
|
|
319
|
+
names = fs.readdirSync(dir, { withFileTypes: true });
|
|
320
|
+
} catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const d of names) {
|
|
324
|
+
if (entries.size >= FS_SNAPSHOT_MAX_ENTRIES) return;
|
|
325
|
+
if (FS_SNAPSHOT_SKIP.has(d.name)) continue;
|
|
326
|
+
const abs = path.join(dir, d.name);
|
|
327
|
+
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
328
|
+
if (d.isDirectory()) walk(abs, r);
|
|
329
|
+
else if (d.isFile()) {
|
|
330
|
+
try {
|
|
331
|
+
const st = fs.statSync(abs);
|
|
332
|
+
entries.set(r, `${st.mtimeMs}:${st.size}`);
|
|
333
|
+
} catch {}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
walk(root, '');
|
|
338
|
+
return entries;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// New or modified files between two fsSnapshots. Deletions are ignored — the
|
|
342
|
+
// signal we need is "did the tick author anything", not a full diff.
|
|
343
|
+
function diffFsSnapshots(before, after) {
|
|
344
|
+
const changed = [];
|
|
345
|
+
for (const [rel, sig] of after) {
|
|
346
|
+
if (before.get(rel) !== sig) changed.push(rel);
|
|
347
|
+
}
|
|
348
|
+
return changed.sort();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The verify default must match the workspace. Defaulting to `npm test` in a
|
|
352
|
+
// root with no package.json guaranteed -1 on any productive tick; a package.json
|
|
353
|
+
// with no "test" script did the same (18 straight -1 ticks in atrisos-backend).
|
|
354
|
+
function defaultVerifyCmd(root) {
|
|
355
|
+
const pkgPath = path.join(root, 'package.json');
|
|
356
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
357
|
+
try {
|
|
358
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
359
|
+
return pkg && pkg.scripts && typeof pkg.scripts.test === 'string' ? 'npm test' : null;
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
133
365
|
// --- ghost / stale detection (pure) ---
|
|
134
366
|
|
|
135
367
|
// Pair started+finished by tick_index; any 'started' with no 'finished' partner
|
|
@@ -365,7 +597,9 @@ function buildTickScript(opts = {}) {
|
|
|
365
597
|
if (!root) throw new Error('buildTickScript: root is required');
|
|
366
598
|
if (!stateHome) throw new Error('buildTickScript: stateHome is required');
|
|
367
599
|
if (!deadlineEpoch) throw new Error('buildTickScript: deadlineEpoch is required');
|
|
368
|
-
|
|
600
|
+
// A null verifyCmd (e.g. non-npm workspace) omits --verify so the tick
|
|
601
|
+
// resolves the workspace-appropriate default at run time.
|
|
602
|
+
const safeVerify = verifyCmd == null ? null : String(verifyCmd).replace(/'/g, "'\\''");
|
|
369
603
|
const runnerModelExport = runnerEnvAliasExport({
|
|
370
604
|
genericName: 'ATRIS_RUNNER_MODEL',
|
|
371
605
|
legacyName: 'ATRIS_CLAUDE_MODEL',
|
|
@@ -412,6 +646,14 @@ now="$(date +%s)"
|
|
|
412
646
|
if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
|
|
413
647
|
crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
|
|
414
648
|
echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
|
|
649
|
+
expired_at="$(date -Iseconds)"
|
|
650
|
+
journal_year="$(date +%Y)"
|
|
651
|
+
journal_day="$(date +%F)"
|
|
652
|
+
journal_file="$ROOT/atris/logs/$journal_year/$journal_day.md"
|
|
653
|
+
mkdir -p "$ROOT/atris/logs/$journal_year" || true
|
|
654
|
+
printf '%s pulse heartbeat expired; renew with: atris pulse install\\n' "$expired_at" >> "$journal_file" || true
|
|
655
|
+
mkdir -p "$ROOT/.atris/state" || true
|
|
656
|
+
printf '{"expired_at":"%s","state_home":"%s","renew_command":"atris pulse install"}\\n' "$expired_at" "$STATE" >> "$ROOT/.atris/state/pulse-expired.json" || true
|
|
415
657
|
exit 0
|
|
416
658
|
fi
|
|
417
659
|
|
|
@@ -428,8 +670,26 @@ ${runnerBinExport}
|
|
|
428
670
|
${runnerCommandTemplateExport}
|
|
429
671
|
export ATRIS_SKIP_UPDATE_CHECK=1
|
|
430
672
|
|
|
431
|
-
"$ATRIS" pulse tick --json --verify '${safeVerify}' >> "$log" 2>&1
|
|
432
|
-
|
|
673
|
+
"$ATRIS" pulse tick --json${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
|
|
674
|
+
tick_status=$?
|
|
675
|
+
|
|
676
|
+
# runDaily no-ops via last_run_date after the first daily run.
|
|
677
|
+
# hourly invocation is safe because repeated calls do no work.
|
|
678
|
+
"$ATRIS" experiments daily >> "$log" 2>&1 || true
|
|
679
|
+
echo "done: $(date -Iseconds) exit=$tick_status" >> "$log"
|
|
680
|
+
|
|
681
|
+
# Ticks commit locally but nothing reaches the remote without this: push any
|
|
682
|
+
# commits the loop has landed since the last successful push.
|
|
683
|
+
if [ -e "$ROOT/.git" ]; then
|
|
684
|
+
branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
|
685
|
+
if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
|
|
686
|
+
ahead="$(git -C "$ROOT" rev-list --count "origin/$branch..$branch" 2>/dev/null || echo 0)"
|
|
687
|
+
if [ "$ahead" -gt 0 ]; then
|
|
688
|
+
git -C "$ROOT" push origin "$branch" >> "$log" 2>&1
|
|
689
|
+
echo "pushed $ahead commit(s) to origin/$branch: $(date -Iseconds)" >> "$log"
|
|
690
|
+
fi
|
|
691
|
+
fi
|
|
692
|
+
fi
|
|
433
693
|
`;
|
|
434
694
|
}
|
|
435
695
|
|
|
@@ -443,18 +703,32 @@ module.exports = {
|
|
|
443
703
|
PULSE_RECEIPT_SCHEMA,
|
|
444
704
|
SCORECARD_SCHEMA,
|
|
445
705
|
PULSE_MARKER,
|
|
706
|
+
LEGACY_STATE_DIRNAME,
|
|
446
707
|
DEFAULT_CADENCE_CRON,
|
|
447
708
|
STALE_TICK_MS,
|
|
448
709
|
LIVENESS_STALE_MS,
|
|
449
710
|
stateDir,
|
|
711
|
+
pulseRepoSlug,
|
|
712
|
+
pulseStateHome,
|
|
713
|
+
pulseMarker,
|
|
714
|
+
legacyPulseStateHome,
|
|
715
|
+
readTickScriptRoot,
|
|
716
|
+
legacyPulseStateMatchesRoot,
|
|
717
|
+
resolvePulseSlot,
|
|
450
718
|
pulseReceiptsPath,
|
|
451
719
|
scorecardsPath,
|
|
452
720
|
pulseCounterPath,
|
|
453
721
|
pulseLockDir,
|
|
454
722
|
buildPulseReceipt,
|
|
455
723
|
buildPulseScorecardRow,
|
|
724
|
+
buildOrbFailureScorecardRow,
|
|
456
725
|
buildInterruptedPulseReceipt,
|
|
726
|
+
ingestOrbScorecard,
|
|
727
|
+
orbRunKey,
|
|
457
728
|
scoreTick,
|
|
729
|
+
fsSnapshot,
|
|
730
|
+
diffFsSnapshots,
|
|
731
|
+
defaultVerifyCmd,
|
|
458
732
|
normalizeExpiryDuration,
|
|
459
733
|
shouldWriteScorecard,
|
|
460
734
|
shouldFallbackToAutopilot,
|