atris 3.70.0 → 3.71.1
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/README.md +18 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +246 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-run-lock.js +59 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +395 -18
- package/lib/member-board.js +7 -1
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +127 -8
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +47 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/commands/compile.js
CHANGED
|
@@ -468,14 +468,25 @@ function executeBuild(root, name, options = {}) {
|
|
|
468
468
|
const cmd = cmdOverride || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Read,Write,Edit,Glob,Grep' });
|
|
469
469
|
const env = { ...process.env };
|
|
470
470
|
delete env.CLAUDECODE;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
471
|
+
// Machine memory and load at both ends of the build, so one killed with
|
|
472
|
+
// SIGKILL says whether the machine was short on memory.
|
|
473
|
+
const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'runner', task: `compile ${name}` });
|
|
474
|
+
try {
|
|
475
|
+
execSync(cmd, {
|
|
476
|
+
cwd: root,
|
|
477
|
+
encoding: 'utf8',
|
|
478
|
+
timeout,
|
|
479
|
+
stdio: verbose ? 'inherit' : 'pipe',
|
|
480
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
481
|
+
env,
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// A kill leaves no report of its own, so the error names the likely cause.
|
|
485
|
+
const end = endResource(err);
|
|
486
|
+
if (end && end.cause && err instanceof Error) err.message = `${err.message}; ${end.cause}`;
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
endResource();
|
|
479
490
|
} finally {
|
|
480
491
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
481
492
|
}
|
package/commands/doctor.js
CHANGED
|
@@ -88,6 +88,48 @@ function installWarnings(packageRoot) {
|
|
|
88
88
|
return warnings;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Outside coding tools a member can run on. The built-in tool (ax) ships
|
|
92
|
+
// with atris and only needs a sign-in, so it is counted through auth.
|
|
93
|
+
const MEMBER_TOOL_BINS = ['claude', 'codex', 'cursor-agent', 'opencode', 'devin', 'grok', 'agy'];
|
|
94
|
+
|
|
95
|
+
function teamMembers(workspaceRoot) {
|
|
96
|
+
const teamDir = path.join(workspaceRoot, 'atris', 'team');
|
|
97
|
+
let entries = [];
|
|
98
|
+
try { entries = fs.readdirSync(teamDir, { withFileTypes: true }); } catch { return []; }
|
|
99
|
+
return entries
|
|
100
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))
|
|
101
|
+
.filter((entry) => fs.existsSync(path.join(teamDir, entry.name, 'MEMBER.md')))
|
|
102
|
+
.map((entry) => entry.name)
|
|
103
|
+
.sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What a member run needs before it can start: someone on the team, git for
|
|
107
|
+
// its own copy of the work, and a coding tool to think with. One fix line
|
|
108
|
+
// names the first missing piece.
|
|
109
|
+
function memberReadiness(workspaceRoot, auth, probe) {
|
|
110
|
+
const members = teamMembers(workspaceRoot);
|
|
111
|
+
const git_installed = probe('git');
|
|
112
|
+
const git_repo = fs.existsSync(path.join(workspaceRoot, '.git'));
|
|
113
|
+
const tools = MEMBER_TOOL_BINS.filter((bin) => probe(bin));
|
|
114
|
+
const built_in_tool = Boolean(auth && auth.signed_in);
|
|
115
|
+
const has_tool = tools.length > 0 || built_in_tool;
|
|
116
|
+
let next = null;
|
|
117
|
+
if (!members.length) next = 'run atris init to add a team to this folder.';
|
|
118
|
+
else if (!git_installed) next = 'install git so members can work on their own copy.';
|
|
119
|
+
else if (!git_repo) next = 'run git init here so members can work on their own copy.';
|
|
120
|
+
else if (!has_tool) next = 'install claude code or codex, or run atris login to use the built-in tool.';
|
|
121
|
+
return {
|
|
122
|
+
ready: next === null,
|
|
123
|
+
members: members.length,
|
|
124
|
+
member_names: members,
|
|
125
|
+
git_installed,
|
|
126
|
+
git_repo,
|
|
127
|
+
tools,
|
|
128
|
+
built_in_tool,
|
|
129
|
+
next,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
91
133
|
function collectDoctor(options = {}) {
|
|
92
134
|
const packageRoot = options.packageRoot || path.join(__dirname, '..');
|
|
93
135
|
const node = parseNodeVersion(options.nodeVersion || process.version);
|
|
@@ -100,6 +142,8 @@ function collectDoctor(options = {}) {
|
|
|
100
142
|
const version_line = formatCliVersionLine(version, packageRoot);
|
|
101
143
|
const install_git = inspectInstallGitState(packageRoot);
|
|
102
144
|
const warnings = installWarnings(packageRoot);
|
|
145
|
+
const probe = options.binInstalled || require('../lib/engine-registry').binInstalled;
|
|
146
|
+
const members = memberReadiness(workspace_root, auth, probe);
|
|
103
147
|
return {
|
|
104
148
|
ok: true,
|
|
105
149
|
action: 'doctor',
|
|
@@ -125,6 +169,7 @@ function collectDoctor(options = {}) {
|
|
|
125
169
|
branch: install_git.branch || null,
|
|
126
170
|
head: install_git.head || null,
|
|
127
171
|
},
|
|
172
|
+
members,
|
|
128
173
|
warnings,
|
|
129
174
|
};
|
|
130
175
|
}
|
|
@@ -140,6 +185,16 @@ function renderDoctor(payload) {
|
|
|
140
185
|
lines.push(`workspace: ${payload.workspace_root}`);
|
|
141
186
|
lines.push(`cli runnable: ${payload.cli_runnable ? 'yes' : 'no'}`);
|
|
142
187
|
lines.push(`public bin: ${payload.bin.public_bin} (${payload.bin.shim ? `${payload.bin.shim_bytes} byte shim` : 'not a shim'})`);
|
|
188
|
+
const m = payload.members;
|
|
189
|
+
if (m) {
|
|
190
|
+
const toolNames = m.tools.concat(m.built_in_tool ? ['built-in'] : []);
|
|
191
|
+
lines.push('');
|
|
192
|
+
lines.push(`members: ${m.ready ? 'ready' : 'not ready yet'}`);
|
|
193
|
+
lines.push(` team: ${m.members ? `${m.members} member${m.members === 1 ? '' : 's'}` : 'none'}`);
|
|
194
|
+
lines.push(` git: ${!m.git_installed ? 'not installed' : (m.git_repo ? 'yes' : 'installed, but this folder is not a git repo')}`);
|
|
195
|
+
lines.push(` coding tool: ${toolNames.length ? toolNames.join(', ') : 'none'}`);
|
|
196
|
+
if (m.next) lines.push(` next: ${m.next}`);
|
|
197
|
+
}
|
|
143
198
|
if (payload.warnings && payload.warnings.length) {
|
|
144
199
|
lines.push('');
|
|
145
200
|
for (const warning of payload.warnings) lines.push(`warning: ${warning}`);
|
|
@@ -156,6 +211,7 @@ function doctorCommand(args = [], options = {}) {
|
|
|
156
211
|
console.log('Usage: atris doctor [--json]');
|
|
157
212
|
console.log('');
|
|
158
213
|
console.log('Reports node version, task-support (22+), auth, workspace root, and whether the CLI can run.');
|
|
214
|
+
console.log('Also checks what members need: a team, git, and a coding tool.');
|
|
159
215
|
console.log('Public install entry is bin/atris (tiny POSIX shim) -> bin/atris.js.');
|
|
160
216
|
return 0;
|
|
161
217
|
}
|
|
@@ -171,4 +227,6 @@ function doctorCommand(args = [], options = {}) {
|
|
|
171
227
|
|
|
172
228
|
module.exports = {
|
|
173
229
|
doctorCommand,
|
|
230
|
+
collectDoctor,
|
|
231
|
+
renderDoctor,
|
|
174
232
|
};
|
package/commands/dream.js
CHANGED
|
@@ -234,6 +234,20 @@ function dreamModels() {
|
|
|
234
234
|
function defaultDreamRunner({ prompt, model, cwd, timeoutMs = DREAM_TIMEOUT_MS }) {
|
|
235
235
|
return new Promise((resolve) => {
|
|
236
236
|
const bin = (RUNNER_PROFILE_DEFS.haiku && RUNNER_PROFILE_DEFS.haiku.bin) || 'claude';
|
|
237
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
238
|
+
// SIGKILL says whether the machine was short on memory.
|
|
239
|
+
const endResource = require('../lib/engine-resource').startEngineRun(cwd, { engine: path.basename(bin), task: 'dream' });
|
|
240
|
+
// The child does not throw, so a failure is passed in the thrown shape.
|
|
241
|
+
const finish = (result, signal = null) => {
|
|
242
|
+
const failed = !result.ok;
|
|
243
|
+
const end = endResource(failed ? {
|
|
244
|
+
code: timedOut ? 'ETIMEDOUT' : '',
|
|
245
|
+
signal,
|
|
246
|
+
stderr: result.stderr,
|
|
247
|
+
message: result.error || `model exited ${result.exitCode}`,
|
|
248
|
+
} : null);
|
|
249
|
+
resolve({ ...result, ...(signal ? { signal } : {}), ...(end && end.cause && !timedOut ? { cause: end.cause } : {}) });
|
|
250
|
+
};
|
|
237
251
|
const proc = spawn(bin, ['-p', prompt, '--model', model], {
|
|
238
252
|
cwd,
|
|
239
253
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -247,13 +261,13 @@ function defaultDreamRunner({ prompt, model, cwd, timeoutMs = DREAM_TIMEOUT_MS }
|
|
|
247
261
|
}, timeoutMs);
|
|
248
262
|
proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
249
263
|
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
250
|
-
proc.on('close', (code) => {
|
|
264
|
+
proc.on('close', (code, signal) => {
|
|
251
265
|
clearTimeout(timer);
|
|
252
|
-
|
|
266
|
+
finish({ ok: code === 0 && !timedOut, stdout, stderr, exitCode: code, timedOut }, signal);
|
|
253
267
|
});
|
|
254
268
|
proc.on('error', (error) => {
|
|
255
269
|
clearTimeout(timer);
|
|
256
|
-
|
|
270
|
+
finish({ ok: false, error: error.message, stdout, stderr, timedOut });
|
|
257
271
|
});
|
|
258
272
|
});
|
|
259
273
|
}
|
|
@@ -262,6 +276,7 @@ function attemptFailureReason(result) {
|
|
|
262
276
|
if (result && result.garbled) return 'could not read dream cards';
|
|
263
277
|
if (result && result.timedOut) return 'model timed out';
|
|
264
278
|
if (result && result.error) return 'model could not start';
|
|
279
|
+
if (result && result.signal) return `model stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`;
|
|
265
280
|
if (result && result.exitCode) return 'model stopped early';
|
|
266
281
|
return 'model did not answer';
|
|
267
282
|
}
|
package/commands/engine.js
CHANGED
|
@@ -1891,7 +1891,7 @@ const PROBE_PROMPT = 'Reply with exactly the two characters: OK';
|
|
|
1891
1891
|
// answer on 2026-07-02. 30s produced a false FAIL on a healthy engine.
|
|
1892
1892
|
const PROBE_DEFAULT_TIMEOUT_MS = 120000;
|
|
1893
1893
|
|
|
1894
|
-
function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
|
|
1894
|
+
function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS, root = process.cwd() } = {}) {
|
|
1895
1895
|
const def = RUNNER_PROFILE_DEFS[name];
|
|
1896
1896
|
if (!binInstalled(def.bin)) {
|
|
1897
1897
|
return {
|
|
@@ -1917,11 +1917,15 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
1917
1917
|
if (prevProfile === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
|
|
1918
1918
|
else process.env.ATRIS_RUNNER_PROFILE = prevProfile;
|
|
1919
1919
|
}
|
|
1920
|
+
// Machine memory and load at both ends of the probe, so one killed with
|
|
1921
|
+
// SIGKILL says whether the machine was short on memory.
|
|
1922
|
+
const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: name, task: 'engine probe' });
|
|
1920
1923
|
const start = Date.now();
|
|
1921
1924
|
let res;
|
|
1922
1925
|
try {
|
|
1923
1926
|
res = spawnSync('sh', ['-c', cmd], { encoding: 'utf8', timeout });
|
|
1924
1927
|
} catch (err) {
|
|
1928
|
+
endResource(err);
|
|
1925
1929
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1926
1930
|
return {
|
|
1927
1931
|
engine: name,
|
|
@@ -1935,6 +1939,15 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
1935
1939
|
};
|
|
1936
1940
|
}
|
|
1937
1941
|
const durationMs = Date.now() - start;
|
|
1942
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
1943
|
+
const launchFailed = res.error || res.signal || res.status !== 0;
|
|
1944
|
+
const end = endResource(launchFailed ? {
|
|
1945
|
+
code: res.error && res.error.code,
|
|
1946
|
+
signal: res.signal,
|
|
1947
|
+
stderr: res.stderr,
|
|
1948
|
+
message: res.error ? res.error.message : `exit ${res.status}`,
|
|
1949
|
+
} : null);
|
|
1950
|
+
const cause = end && end.cause ? `; ${end.cause}` : '';
|
|
1938
1951
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1939
1952
|
const stdout = res.stdout || '';
|
|
1940
1953
|
const stderr = res.stderr || '';
|
|
@@ -1954,7 +1967,7 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
1954
1967
|
reason,
|
|
1955
1968
|
message: ok
|
|
1956
1969
|
? 'responded OK'
|
|
1957
|
-
: (timedOut ? `no reply within ${timeout}ms` : `did not reply OK (exit ${res.status}, signal ${res.signal})`),
|
|
1970
|
+
: (timedOut ? `no reply within ${timeout}ms` : `did not reply OK (exit ${res.status}, signal ${res.signal})${cause}`),
|
|
1958
1971
|
stdout,
|
|
1959
1972
|
stderr,
|
|
1960
1973
|
durationMs,
|
|
@@ -2032,7 +2045,7 @@ function runBenchCommand(args, root, deps = {}) {
|
|
|
2032
2045
|
}
|
|
2033
2046
|
}
|
|
2034
2047
|
|
|
2035
|
-
const probeOpts = {};
|
|
2048
|
+
const probeOpts = { root };
|
|
2036
2049
|
if (timeout) probeOpts.timeout = timeout;
|
|
2037
2050
|
|
|
2038
2051
|
const results = [];
|
|
@@ -2154,7 +2167,7 @@ function runEngineTest(targets, { json, root } = {}) {
|
|
|
2154
2167
|
return 1;
|
|
2155
2168
|
}
|
|
2156
2169
|
}
|
|
2157
|
-
const results = enginesToTest.map((name) => probeEngine(name));
|
|
2170
|
+
const results = enginesToTest.map((name) => probeEngine(name, { root }));
|
|
2158
2171
|
const failures = results.filter((r) => !r.pass);
|
|
2159
2172
|
const passed = results.length - failures.length;
|
|
2160
2173
|
// A live probe is the freshest health evidence there is: write it back so
|
|
@@ -2314,6 +2327,132 @@ function shorthandModelError(engine, args, root) {
|
|
|
2314
2327
|
}
|
|
2315
2328
|
}
|
|
2316
2329
|
|
|
2330
|
+
function formatAgentAge(seconds) {
|
|
2331
|
+
const minutes = Math.floor(seconds / 60);
|
|
2332
|
+
const hours = Math.floor(minutes / 60);
|
|
2333
|
+
const days = Math.floor(hours / 24);
|
|
2334
|
+
if (days) return `${days}d ${hours % 24}h`;
|
|
2335
|
+
if (hours) return `${hours}h ${minutes % 60}m`;
|
|
2336
|
+
return `${minutes}m`;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// atris engine sweep: list ssh-agents that engine shells started and left
|
|
2340
|
+
// behind. With --apply it rechecks each one and stops it with SIGTERM, and
|
|
2341
|
+
// writes a parent gone end line for each run whose parent process is gone.
|
|
2342
|
+
function runEngineSweepCommand(args, root, deps = {}) {
|
|
2343
|
+
const { readSshAgents, findLeakedSshAgents, findAgentStartLines, agentStartLineText, stopLeakedSshAgents, closeLostRuns, readResourceHistory, leakGrowthNote, LEAK_MIN_AGE_SEC } = require('../lib/engine-resource');
|
|
2344
|
+
const read = deps.read || readSshAgents;
|
|
2345
|
+
const { agents, referenced, unread = [] } = read();
|
|
2346
|
+
const uid = deps.uid !== undefined ? deps.uid : process.getuid();
|
|
2347
|
+
const leaked = findLeakedSshAgents(agents, { referenced, uid });
|
|
2348
|
+
const apply = args.includes('--apply');
|
|
2349
|
+
const result = apply
|
|
2350
|
+
? stopLeakedSshAgents(root, leaked, { read, uid, ...(deps.kill ? { kill: deps.kill } : {}), ...(deps.now ? { now: deps.now } : {}) })
|
|
2351
|
+
: { stopped: [], failed: [], skipped: [] };
|
|
2352
|
+
result.unread = [...new Set([...unread, ...(result.unread || [])])];
|
|
2353
|
+
const scanWarning = result.unread.length
|
|
2354
|
+
? `could not read the ${result.unread.join(' or the ')} for every process, so some ssh-agents could not be checked; the leak count may be incomplete`
|
|
2355
|
+
: null;
|
|
2356
|
+
result.closed_runs = [];
|
|
2357
|
+
if (apply) {
|
|
2358
|
+
// Agents may already be stopped here, so a failed close is reported
|
|
2359
|
+
// beside them rather than thrown away with the whole report.
|
|
2360
|
+
try {
|
|
2361
|
+
result.closed_runs = closeLostRuns(root, deps.now ? { now: deps.now } : {});
|
|
2362
|
+
} catch (err) {
|
|
2363
|
+
result.close_error = String((err && err.message) || err).slice(0, 200);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
// A test run reads only the home it was handed, never the real one.
|
|
2367
|
+
const startLines = leaked.length && (deps.home || !process.env.NODE_TEST_CONTEXT)
|
|
2368
|
+
? findAgentStartLines(deps.home ? { home: deps.home } : {})
|
|
2369
|
+
: [];
|
|
2370
|
+
const code = result.failed.length || result.close_error ? 1 : 0;
|
|
2371
|
+
if (args.includes('--json')) {
|
|
2372
|
+
console.log(JSON.stringify({ running: agents.length, leaked, unread, ...result, agent_start_lines: startLines, history: readResourceHistory(root) }, null, 2));
|
|
2373
|
+
return code;
|
|
2374
|
+
}
|
|
2375
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
2376
|
+
if (apply) {
|
|
2377
|
+
const lines = [`stopped ${plural(result.stopped.length, 'leaked ssh-agent')}`];
|
|
2378
|
+
const replaced = (result.replaced || []).length;
|
|
2379
|
+
const otherSkipped = result.skipped.length - replaced;
|
|
2380
|
+
if (replaced) lines.push(`${replaced} skipped because ${replaced === 1 ? 'a younger ssh-agent now has' : 'younger ssh-agents now have'} the same process number`);
|
|
2381
|
+
if (otherSkipped) lines.push(`${otherSkipped} skipped because they were in use or gone on the recheck`);
|
|
2382
|
+
for (const { pid, code: reason } of result.failed) lines.push(`could not stop pid ${pid}: ${reason}`);
|
|
2383
|
+
if (result.close_error) lines.push(`could not close lost runs, so unfinished records may remain: ${result.close_error}`);
|
|
2384
|
+
else lines.push(`closed ${plural(result.closed_runs.length, 'run')} whose parent is gone`);
|
|
2385
|
+
if (scanWarning) lines.push(scanWarning);
|
|
2386
|
+
console.log(lines.join('\n'));
|
|
2387
|
+
return code;
|
|
2388
|
+
}
|
|
2389
|
+
const shown = 20;
|
|
2390
|
+
const history = readResourceHistory(root);
|
|
2391
|
+
const lines = [
|
|
2392
|
+
`${plural(agents.length, 'ssh-agent')} running, ${leaked.length} ${leaked.length === 1 ? 'looks' : 'look'} leaked${leakGrowthNote(history.first_day_leak_reading, leaked.length)}`
|
|
2393
|
+
+ ` (no parent, older than ${LEAK_MIN_AGE_SEC / 60} min, not in use by any live shell)`,
|
|
2394
|
+
...leaked.slice(0, shown).map((agent) => ` pid ${String(agent.pid).padEnd(6)} ${formatAgentAge(agent.age_sec)}`),
|
|
2395
|
+
];
|
|
2396
|
+
if (leaked.length > shown) lines.push(` and ${leaked.length - shown} more`);
|
|
2397
|
+
lines.push(...startLines.map(agentStartLineText));
|
|
2398
|
+
if (scanWarning) lines.push(scanWarning);
|
|
2399
|
+
lines.push(leaked.length ? 'nothing was stopped. run atris engine sweep --apply to stop them.' : 'nothing was stopped.');
|
|
2400
|
+
lines.push('', ...resourceHistoryLines(history));
|
|
2401
|
+
console.log(lines.join('\n'));
|
|
2402
|
+
return 0;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function utcMinute(at) {
|
|
2406
|
+
return `${String(at || '').slice(0, 16).replace('T', ' ')} UTC`;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// What the resource log says about past runs: the newest sweep, runs with no
|
|
2410
|
+
// end, the newest run that failed without being killed and why, the newest
|
|
2411
|
+
// SIGKILL and its likely cause, and how many SIGKILLs came with low memory.
|
|
2412
|
+
function resourceHistoryLines(history) {
|
|
2413
|
+
const { errorReason } = require('../lib/engine-resource');
|
|
2414
|
+
const lines = [];
|
|
2415
|
+
if (history.read_error) {
|
|
2416
|
+
lines.push(`could not read the engine run log: ${history.read_error}, so past sweeps, kills, and errors are unknown`);
|
|
2417
|
+
return lines;
|
|
2418
|
+
}
|
|
2419
|
+
const sweep = history.last_sweep;
|
|
2420
|
+
lines.push(sweep
|
|
2421
|
+
? `last sweep: ${utcMinute(sweep.at)}, stopped ${(sweep.stopped || []).length}`
|
|
2422
|
+
: 'no sweep recorded yet');
|
|
2423
|
+
const started = history.last_open;
|
|
2424
|
+
if (started) {
|
|
2425
|
+
const unrecorded = history.open_runs - history.parent_gone - history.still_running;
|
|
2426
|
+
const parts = [
|
|
2427
|
+
history.parent_gone && `${history.parent_gone} whose parent is gone`,
|
|
2428
|
+
history.still_running && `${history.still_running} still running`,
|
|
2429
|
+
unrecorded && `${unrecorded} from before parents were recorded`,
|
|
2430
|
+
].filter(Boolean);
|
|
2431
|
+
lines.push(`${history.open_runs} run${history.open_runs === 1 ? '' : 's'} started with no end recorded: ${parts.join(', ')};`
|
|
2432
|
+
+ ` newest: ${started.engine || 'engine'} on ${started.task || 'an unnamed task'} at ${utcMinute(started.at)}`);
|
|
2433
|
+
}
|
|
2434
|
+
const failed = history.last_error;
|
|
2435
|
+
if (failed) {
|
|
2436
|
+
lines.push(`last engine error: ${failed.engine || 'engine'} on ${failed.task || 'an unnamed task'} at ${utcMinute(failed.at)}: ${errorReason(failed)}`);
|
|
2437
|
+
}
|
|
2438
|
+
const kill = history.last_kill;
|
|
2439
|
+
if (!kill) {
|
|
2440
|
+
lines.push('no engine run has ended in SIGKILL');
|
|
2441
|
+
return lines;
|
|
2442
|
+
}
|
|
2443
|
+
const killLine = (entry) => {
|
|
2444
|
+
const free = Number.isFinite(entry.free_pct) ? `, ${entry.free_pct}% memory free` : '';
|
|
2445
|
+
return `${entry.engine || 'engine'} on ${entry.task || 'an unnamed task'} at ${utcMinute(entry.at)}, ${entry.cause}${free}`;
|
|
2446
|
+
};
|
|
2447
|
+
lines.push(`last SIGKILL: ${killLine(kill)}`);
|
|
2448
|
+
// Timeouts can pile up after a crash; still name the newest kill that was not one.
|
|
2449
|
+
const other = history.last_other_kill;
|
|
2450
|
+
if (other && other !== kill) lines.push(`last SIGKILL not at a time limit: ${killLine(other)}`);
|
|
2451
|
+
const timeLimit = history.time_limit_kills ? `, ${history.time_limit_kills} at their time limit` : '';
|
|
2452
|
+
lines.push(`${history.low_memory_kills} of ${history.kills} SIGKILL${history.kills === 1 ? '' : 's'} ended with machine memory low${timeLimit}`);
|
|
2453
|
+
return lines;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2317
2456
|
function engineCommand(args = [], deps = {}) {
|
|
2318
2457
|
const root = deps.root || process.cwd();
|
|
2319
2458
|
const dispatch = deps.engineDispatch || runDispatchCommand;
|
|
@@ -2323,6 +2462,9 @@ function engineCommand(args = [], deps = {}) {
|
|
|
2323
2462
|
if ((args[0] || '').trim() === 'watch') {
|
|
2324
2463
|
return runEngineWatchCommand(args.slice(1), root, deps.engineWatch || {});
|
|
2325
2464
|
}
|
|
2465
|
+
if ((args[0] || '').trim() === 'sweep') {
|
|
2466
|
+
return runEngineSweepCommand(args.slice(1), root, deps.engineSweep || {});
|
|
2467
|
+
}
|
|
2326
2468
|
if ((args[0] || '').trim() === 'validate') {
|
|
2327
2469
|
return runEngineValidateCommand(args.slice(1), root, deps.engineValidate || {});
|
|
2328
2470
|
}
|
|
@@ -2406,7 +2548,7 @@ function engineCommand(args = [], deps = {}) {
|
|
|
2406
2548
|
if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
2407
2549
|
console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
|
|
2408
2550
|
console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
|
|
2409
|
-
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2551
|
+
console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine sweep [--apply] [--json]\n list ssh-agents that engine shells left running; --apply rechecks and stops them\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
|
|
2410
2552
|
return 0;
|
|
2411
2553
|
}
|
|
2412
2554
|
if (isFreshWorkspace(root)) {
|
package/commands/improve.js
CHANGED
|
@@ -535,13 +535,17 @@ function collectImproveVitals(options = {}, deps = {}) {
|
|
|
535
535
|
const collect = deps.collectRevisionSignals || collectRevisionSignals;
|
|
536
536
|
const rev = collect(root, { days: 14, now: nowMs });
|
|
537
537
|
const landingsPhrase = `${countWord(rev.landings)} landing${rev.landings === 1 ? '' : 's'} this fortnight`;
|
|
538
|
-
const
|
|
538
|
+
const fixed = Number.isFinite(rev.fixed) ? rev.fixed : rev.revised;
|
|
539
|
+
const fixPhrase = `${countWord(fixed)} needed a human fix`;
|
|
540
|
+
const coachPhrase = rev.coached ? `, ${countWord(rev.coached)} got your coaching` : '';
|
|
539
541
|
guarantee = {
|
|
540
542
|
days: rev.days,
|
|
541
543
|
landings: rev.landings,
|
|
542
544
|
revised: rev.revised,
|
|
545
|
+
fixed,
|
|
546
|
+
coached: rev.coached || 0,
|
|
543
547
|
rate: rev.rate,
|
|
544
|
-
sentence: plainSentence(`${landingsPhrase}, ${fixPhrase}.`),
|
|
548
|
+
sentence: plainSentence(`${landingsPhrase}, ${fixPhrase}${coachPhrase}.`),
|
|
545
549
|
};
|
|
546
550
|
} catch {
|
|
547
551
|
guarantee = null;
|
|
@@ -1025,7 +1029,7 @@ function formatImproveReport(result = {}) {
|
|
|
1025
1029
|
// those trailer lines. commits with no trailer still count as human, so the
|
|
1026
1030
|
// metric overcounts revisions; accepted on purpose.
|
|
1027
1031
|
//
|
|
1028
|
-
// if a human commit
|
|
1032
|
+
// if a human commit changes lines the landing wrote within 72 hours, that
|
|
1029
1033
|
// landing failed the guarantee.
|
|
1030
1034
|
//
|
|
1031
1035
|
// renames are NOT followed: `git log --follow` is per-file and would cost one
|
|
@@ -1038,8 +1042,13 @@ const REVISION_WINDOW_HOURS = 72;
|
|
|
1038
1042
|
// matter of routine (daily logs, now.md, run receipts), so overlap there says
|
|
1039
1043
|
// nothing about the quality of a landing. On 2026-08-21 half the "revisions"
|
|
1040
1044
|
// in a live reading were journal commits following journal commits. Only
|
|
1041
|
-
// product-file overlap counts.
|
|
1045
|
+
// product-file overlap counts. The same holds for shared index files: on
|
|
1046
|
+
// 2026-10-15 one human commit read as 14 of 17 revisions because it moved a
|
|
1047
|
+
// few map-note lines that nearly every landing also adds to.
|
|
1042
1048
|
const REVISION_IGNORED_FILES = [
|
|
1049
|
+
/^atris\/MAP\.md$/,
|
|
1050
|
+
/^atris\/refs\/MAP-NOTES\.md$/,
|
|
1051
|
+
/^test\/fast-tests\.txt$/,
|
|
1043
1052
|
/^atris\/logs\//,
|
|
1044
1053
|
/^atris\/runs\//,
|
|
1045
1054
|
/^atris\/team\/[^/]+\/(?:logs|now\.md)/,
|
|
@@ -1052,6 +1061,23 @@ const REVISION_IGNORED_FILES = [
|
|
|
1052
1061
|
function isRevisionSignalFile(file) {
|
|
1053
1062
|
return !REVISION_IGNORED_FILES.some((re) => re.test(String(file || '')));
|
|
1054
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// A person editing a member's role, goals, context, or the team roster after a
|
|
1066
|
+
// landing is coaching the team, not fixing broken work. Live 2026-10-15: most
|
|
1067
|
+
// of 51 "revisions" in atris-desktop-v2 were goals.json and MEMBER.md edits,
|
|
1068
|
+
// so the boot line told the operator they fixed work they had only steered.
|
|
1069
|
+
const COACHING_FILES = [
|
|
1070
|
+
/^atris\/team\/[^/]+\/(?:MEMBER\.md|MISSION\.md|WAKE\.md|lessons\.md|goals\.(?:json|md))$/,
|
|
1071
|
+
/^atris\/team\/[^/]+\/(?:context|skills)\//,
|
|
1072
|
+
/^atris\/team\/ROSTER\.(?:json|md)$/,
|
|
1073
|
+
/^atris\/ROSTER\.md$/,
|
|
1074
|
+
/^atris\/(?:PERSONA|STANDARD)\.md$/,
|
|
1075
|
+
/^atris\/policies\//,
|
|
1076
|
+
];
|
|
1077
|
+
|
|
1078
|
+
function isCoachingFile(file) {
|
|
1079
|
+
return COACHING_FILES.some((re) => re.test(String(file || '')));
|
|
1080
|
+
}
|
|
1055
1081
|
const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * 60 * 60 * 1000;
|
|
1056
1082
|
const AGENT_TRAILER_MARKERS = [
|
|
1057
1083
|
'atris-builder[bot]',
|
|
@@ -1213,7 +1239,7 @@ function loadRevisionFilesCache(root) {
|
|
|
1213
1239
|
|
|
1214
1240
|
function saveRevisionFilesCache(cache) {
|
|
1215
1241
|
if (!cache.writable || !cache.dirty) return;
|
|
1216
|
-
const entries =
|
|
1242
|
+
const entries = Object.fromEntries(Object.entries(cache.used).filter(([, files]) => Array.isArray(files)));
|
|
1217
1243
|
let count = Object.keys(entries).length;
|
|
1218
1244
|
for (const [hash, files] of Object.entries(cache.entries)) {
|
|
1219
1245
|
if (count >= REVISION_FILES_CACHE_MAX) break;
|
|
@@ -1224,9 +1250,61 @@ function saveRevisionFilesCache(cache) {
|
|
|
1224
1250
|
ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
|
|
1225
1251
|
}
|
|
1226
1252
|
|
|
1253
|
+
/**
|
|
1254
|
+
* Which mainline commit brought each commit onto the line HEAD follows: a
|
|
1255
|
+
* mainline commit brings itself, a branch commit arrives with the oldest
|
|
1256
|
+
* merge that reaches it. Built from the history already read, so no extra git
|
|
1257
|
+
* calls. Commits outside the window stay unknown.
|
|
1258
|
+
*/
|
|
1259
|
+
function mainlineArrivals(commits) {
|
|
1260
|
+
const byHash = new Map(commits.map((commit) => [commit.hash, commit]));
|
|
1261
|
+
const mainline = [];
|
|
1262
|
+
for (let c = commits[0]; c && !mainline.includes(c); c = byHash.get(c.parents[0])) mainline.push(c);
|
|
1263
|
+
const arrival = new Map(mainline.map((m) => [m.hash, m.hash]));
|
|
1264
|
+
for (const m of mainline.slice().reverse()) {
|
|
1265
|
+
const stack = m.parents.slice(1);
|
|
1266
|
+
while (stack.length) {
|
|
1267
|
+
const hash = stack.pop();
|
|
1268
|
+
if (arrival.has(hash) || !byHash.has(hash)) continue;
|
|
1269
|
+
arrival.set(hash, m.hash);
|
|
1270
|
+
stack.push(...byHash.get(hash).parents);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return arrival;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Commits that wrote the lines a plain commit changed in one file, read with
|
|
1278
|
+
* blame on its parent. A pure insertion counts the line it went in under, so
|
|
1279
|
+
* adding a missing line to someone's block still touches that block. null
|
|
1280
|
+
* when git cannot say (binary, new file, blame error); the caller then falls
|
|
1281
|
+
* back to same-file overlap.
|
|
1282
|
+
*/
|
|
1283
|
+
function linesAuthoredBefore(cwdRoot, commit, file) {
|
|
1284
|
+
const parent = commit.parents[0];
|
|
1285
|
+
if (!parent) return null;
|
|
1286
|
+
try {
|
|
1287
|
+
const diff = gitLines(cwdRoot, [...NAME_ARGS, 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', parent, commit.hash, '--', file]);
|
|
1288
|
+
const ranges = [];
|
|
1289
|
+
for (const m of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? /gm)) {
|
|
1290
|
+
const start = Number(m[1]);
|
|
1291
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
1292
|
+
if (count > 0) ranges.push('-L', `${start},${start + count - 1}`);
|
|
1293
|
+
else ranges.push('-L', `${Math.max(start, 1)},${Math.max(start, 1)}`);
|
|
1294
|
+
}
|
|
1295
|
+
if (!ranges.length) return null;
|
|
1296
|
+
const blame = gitLines(cwdRoot, [...NAME_ARGS, 'blame', '--porcelain', ...ranges, parent, '--', file]);
|
|
1297
|
+
const authors = new Set();
|
|
1298
|
+
for (const m of blame.matchAll(/^([0-9a-f]{40,64}) \d+ \d+/gm)) authors.add(m[1]);
|
|
1299
|
+
return [...authors];
|
|
1300
|
+
} catch {
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1227
1305
|
/**
|
|
1228
1306
|
* Read the last N days of history and pair every agent landing with the
|
|
1229
|
-
* human commits that
|
|
1307
|
+
* human commits that changed its lines within the 72-hour window.
|
|
1230
1308
|
*/
|
|
1231
1309
|
function collectRevisionSignals(root, options = {}) {
|
|
1232
1310
|
const days = Number.isFinite(options.days) && options.days > 0 ? Math.round(options.days) : DEFAULT_REVISIONS_DAYS;
|
|
@@ -1288,25 +1366,48 @@ function collectRevisionSignals(root, options = {}) {
|
|
|
1288
1366
|
}
|
|
1289
1367
|
return cache.used[commit.hash];
|
|
1290
1368
|
};
|
|
1369
|
+
// Saved beside the file lists under a key no commit hash can take.
|
|
1370
|
+
const authorsOf = (commit, file) => {
|
|
1371
|
+
const key = `lines:${commit.hash}:${file}`;
|
|
1372
|
+
if (!(key in cache.used)) {
|
|
1373
|
+
const hit = cache.entries[key];
|
|
1374
|
+
cache.used[key] = Array.isArray(hit) ? hit : linesAuthoredBefore(root, commit, file);
|
|
1375
|
+
if (!Array.isArray(hit) && cache.used[key]) cache.dirty = true;
|
|
1376
|
+
}
|
|
1377
|
+
return cache.used[key];
|
|
1378
|
+
};
|
|
1291
1379
|
|
|
1380
|
+
// A commit that reached the mainline in the same merge as the landing came
|
|
1381
|
+
// in with it, before anything had landed for a person to fix.
|
|
1382
|
+
const arrival = mainlineArrivals(commits);
|
|
1292
1383
|
const revisions = [];
|
|
1293
1384
|
for (const landing of landings) {
|
|
1294
1385
|
const landedFiles = new Set(filesOf(landing).filter(isRevisionSignalFile));
|
|
1295
1386
|
if (!landedFiles.size) continue;
|
|
1387
|
+
const landedWith = arrival.get(landing.hash);
|
|
1296
1388
|
const revisedBy = [];
|
|
1297
1389
|
const touched = new Set();
|
|
1298
1390
|
for (const human of humans) {
|
|
1299
1391
|
if (human.ms <= landing.ms || human.ms > landing.ms + REVISION_WINDOW_MS) continue;
|
|
1300
|
-
|
|
1392
|
+
if (landedWith && arrival.get(human.hash) === landedWith) continue;
|
|
1393
|
+
// Same file is not enough: the commit has to change lines that came in
|
|
1394
|
+
// with the landing. Live 2026-10-15: a follow-up to one change read as
|
|
1395
|
+
// a fix of four older landings that only shared its files.
|
|
1396
|
+
const overlap = filesOf(human).filter((f) => landedFiles.has(f)).filter((f) => {
|
|
1397
|
+
const authors = authorsOf(human, f);
|
|
1398
|
+
return !authors || authors.some((h) => h === landing.hash || (landedWith && arrival.get(h) === landedWith));
|
|
1399
|
+
});
|
|
1301
1400
|
if (!overlap.length) continue;
|
|
1302
1401
|
revisedBy.push({ hash: human.hash, subject: human.subject, at: human.at });
|
|
1303
1402
|
overlap.forEach((f) => touched.add(f));
|
|
1304
1403
|
}
|
|
1305
1404
|
if (revisedBy.length) {
|
|
1405
|
+
const files = [...touched].sort();
|
|
1306
1406
|
revisions.push({
|
|
1307
1407
|
landing: { hash: landing.hash, subject: landing.subject, at: landing.at },
|
|
1308
1408
|
revised_by: revisedBy,
|
|
1309
|
-
files
|
|
1409
|
+
files,
|
|
1410
|
+
kind: files.every(isCoachingFile) ? 'coaching' : 'fix',
|
|
1310
1411
|
});
|
|
1311
1412
|
}
|
|
1312
1413
|
}
|
|
@@ -1320,6 +1421,9 @@ function collectRevisionSignals(root, options = {}) {
|
|
|
1320
1421
|
landings: landings.length,
|
|
1321
1422
|
revised: revisions.length,
|
|
1322
1423
|
rate: landings.length ? revisions.length / landings.length : 0,
|
|
1424
|
+
fixed: revisions.filter((r) => r.kind === 'fix').length,
|
|
1425
|
+
coached: revisions.filter((r) => r.kind === 'coaching').length,
|
|
1426
|
+
fix_rate: landings.length ? revisions.filter((r) => r.kind === 'fix').length / landings.length : 0,
|
|
1323
1427
|
revisions,
|
|
1324
1428
|
};
|
|
1325
1429
|
}
|
|
@@ -1339,9 +1443,15 @@ function formatRevisionsReport(summary = {}) {
|
|
|
1339
1443
|
lines.push(`agent landings in the last ${plural(days, 'day')}: ${summary.landings}.`);
|
|
1340
1444
|
lines.push(`landings a human then revised: ${summary.revised}.`);
|
|
1341
1445
|
lines.push(`revision rate: ${Math.round((summary.rate || 0) * 100)} percent. the target is zero.`);
|
|
1446
|
+
if (Number.isFinite(summary.fixed)) {
|
|
1447
|
+
lines.push(`real fixes: ${summary.fixed}. coaching (roles, goals, context): ${summary.coached || 0}.`);
|
|
1448
|
+
}
|
|
1342
1449
|
for (const item of Array.isArray(summary.revisions) ? summary.revisions : []) {
|
|
1343
1450
|
lines.push('');
|
|
1344
|
-
|
|
1451
|
+
const after = item.kind === 'coaching'
|
|
1452
|
+
? `a human then coached the team in ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`
|
|
1453
|
+
: `a human then changed ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`;
|
|
1454
|
+
lines.push(`an agent landed "${plainSentence(item.landing.subject)}". ${after}`);
|
|
1345
1455
|
}
|
|
1346
1456
|
return lines.join('\n');
|
|
1347
1457
|
}
|