atris 3.82.2 → 3.82.4
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 +1 -1
- package/atris/atris.md +6 -0
- package/bin/atris.js +1 -0
- package/commands/aeo.js +15 -2
- package/commands/next.js +16 -6
- package/commands/now.js +6 -1
- package/commands/security-review.js +239 -8
- package/commands/sentinel-powers.js +284 -0
- package/commands/task.js +9 -2
- package/lib/agent-next.js +255 -0
- package/lib/campaign-keep.js +1 -0
- package/lib/first-minute.js +13 -6
- package/lib/member-context.js +11 -2
- package/lib/security-audit.js +191 -12
- package/lib/sentinel-agents.js +584 -0
- package/lib/sentinel-cloud.js +561 -0
- package/lib/sentinel-events.js +320 -0
- package/lib/sentinel-guard-install.js +163 -0
- package/lib/sentinel-guard.js +856 -0
- package/lib/sentinel-watch.js +529 -0
- package/lib/task-core.js +3 -3
- package/lib/task-db.js +12 -3
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The free sentinel commands: agents, guard, watch and flush. Each reports
|
|
4
|
+
// to the dashboard through POST /api/sentinel/events (lib/sentinel-events.js).
|
|
5
|
+
//
|
|
6
|
+
// atris sentinel agents [path] [--home] check an agent setup
|
|
7
|
+
// atris sentinel guard install|uninstall|status|test [--global]
|
|
8
|
+
// atris sentinel watch [path] [--fix] known attacks in dependencies
|
|
9
|
+
// atris sentinel flush send queued guard events
|
|
10
|
+
//
|
|
11
|
+
// An upload problem is a warning line. It never changes the exit code.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
function tildePath(p, home = require('os').homedir()) {
|
|
17
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseFlags(argv, known) {
|
|
21
|
+
const opts = { paths: [] };
|
|
22
|
+
for (const a of argv) {
|
|
23
|
+
if (a.startsWith('--')) {
|
|
24
|
+
const name = a.slice(2);
|
|
25
|
+
if (!known.includes(name)) throw new Error(`unknown flag: ${a}`);
|
|
26
|
+
opts[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
|
|
27
|
+
} else if (a.startsWith('-') && a !== '-') {
|
|
28
|
+
throw new Error(`unknown flag: ${a}`);
|
|
29
|
+
} else opts.paths.push(a);
|
|
30
|
+
}
|
|
31
|
+
return opts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveRoot(opts, deps) {
|
|
35
|
+
const root = path.resolve(opts.paths[0] || deps.root || process.cwd());
|
|
36
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${root} is not a folder`);
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tokenFor(deps) {
|
|
41
|
+
if (deps.token !== undefined) return deps.token;
|
|
42
|
+
return require('../lib/sentinel-cloud').authToken();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uploadLine(res, what) {
|
|
46
|
+
if (res.skipped === 'no-upload') return ' kept on this machine. nothing was sent (--no-upload)';
|
|
47
|
+
if (res.skipped === 'not-logged-in') return ' not logged in. nothing was sent. run atris login to see this on your dashboard';
|
|
48
|
+
if (!res.ok) return ` warning: could not send ${what} to your atris dashboard: ${res.reason}. the check itself is done`;
|
|
49
|
+
const resolved = res.resolved ? ` and marked ${res.resolved} as fixed` : '';
|
|
50
|
+
return ` sent ${res.sent - (res.resolved || 0)} ${what} to your atris dashboard${resolved}. no file contents or secret values were sent`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- agents
|
|
54
|
+
|
|
55
|
+
async function runAgents(root, { home = false, upload = true, deps = {} } = {}) {
|
|
56
|
+
const agents = require('../lib/sentinel-agents');
|
|
57
|
+
const events = require('../lib/sentinel-events');
|
|
58
|
+
const homeDir = deps.homeDir || process.env.HOME || require('os').homedir();
|
|
59
|
+
const result = agents.runAgentCheck(root, { home, homeDir });
|
|
60
|
+
const evs = agents.toEvents(result, { repo: deps.repo });
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await events.reportFindings(root, 'agent_check', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload });
|
|
64
|
+
} catch (e) { res = { ok: false, reason: e.message }; }
|
|
65
|
+
return { result, events: evs, upload: res };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function agentsCommand(argv, deps = {}) {
|
|
69
|
+
let opts;
|
|
70
|
+
try { opts = parseFlags(argv, ['home', 'json', 'no-upload']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
71
|
+
let root;
|
|
72
|
+
try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
73
|
+
const { result, upload } = await runAgents(root, { home: Boolean(opts.home), upload: !opts.noUpload, deps });
|
|
74
|
+
if (opts.json) {
|
|
75
|
+
console.log(JSON.stringify({ ok: result.grade !== 'fail', grade: result.grade, areas: result.areas, files: result.files, findings: result.findings, upload }, null, 2));
|
|
76
|
+
} else {
|
|
77
|
+
console.log(require('../lib/sentinel-agents').renderReport(result));
|
|
78
|
+
console.log(uploadLine(upload, 'the findings'));
|
|
79
|
+
console.log('');
|
|
80
|
+
}
|
|
81
|
+
return result.grade === 'fail' ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------- watch
|
|
85
|
+
|
|
86
|
+
async function runWatchCheck(root, { upload = true, deps = {} } = {}) {
|
|
87
|
+
const watch = require('../lib/sentinel-watch');
|
|
88
|
+
const events = require('../lib/sentinel-events');
|
|
89
|
+
const result = await watch.runWatch(root, { base: deps.osvBase || process.env.ATRIS_OSV_BASE || watch.OSV_BASE, ...(deps.request ? { request: deps.request } : {}) });
|
|
90
|
+
const evs = watch.toEvents(result, { repo: deps.repo });
|
|
91
|
+
let res;
|
|
92
|
+
if (result.offline) res = { ok: true, skipped: 'offline', sent: 0 };
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
res = await events.reportFindings(root, 'threat', evs, { token: upload ? tokenFor(deps) : '', base: deps.base, env: deps.env || process.env, upload });
|
|
96
|
+
} catch (e) { res = { ok: false, reason: e.message }; }
|
|
97
|
+
}
|
|
98
|
+
return { result, events: evs, upload: res };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function watchCommand(argv, deps = {}) {
|
|
102
|
+
let opts;
|
|
103
|
+
try { opts = parseFlags(argv, ['fix', 'json', 'no-upload']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
104
|
+
let root;
|
|
105
|
+
try { root = resolveRoot(opts, deps); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
106
|
+
const watch = require('../lib/sentinel-watch');
|
|
107
|
+
const { result, upload } = await runWatchCheck(root, { upload: !opts.noUpload, deps });
|
|
108
|
+
let fix = null;
|
|
109
|
+
if (opts.fix && !result.offline && result.affected.length) {
|
|
110
|
+
try { fix = watch.applyFix(root, result, { log: (m) => console.error(` ${m}`) }); } catch (e) { fix = { ok: false, reason: String(e.message || e).split('\n')[0] }; }
|
|
111
|
+
}
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify({ ok: !result.affected.some((a) => a.severity === 'critical' || a.severity === 'high'), ...result, upload, fix }, null, 2));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(watch.renderReport(result));
|
|
116
|
+
if (upload.skipped !== 'offline') console.log(uploadLine(upload, 'the threats'));
|
|
117
|
+
if (fix) {
|
|
118
|
+
console.log('');
|
|
119
|
+
if (!fix.ok) console.log(` the fix did not run: ${fix.reason}`);
|
|
120
|
+
else if (!fix.branch) console.log(` the fix changed nothing${fix.message ? `: ${fix.message}` : ''}`);
|
|
121
|
+
else {
|
|
122
|
+
console.log(` updated ${fix.changed} file${fix.changed === 1 ? '' : 's'} on a new branch: ${fix.branch}`);
|
|
123
|
+
console.log(` review it with: git diff ${fix.from || 'main'}...${fix.branch}`);
|
|
124
|
+
console.log(` go back with: git checkout ${fix.from || 'main'}`);
|
|
125
|
+
console.log(' nothing was pushed');
|
|
126
|
+
}
|
|
127
|
+
if (fix.manual && fix.manual.length) {
|
|
128
|
+
console.log(` update these yourself: ${fix.manual.map((a) => `${a.name} to ${a.fixed}`).join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
console.log('');
|
|
132
|
+
}
|
|
133
|
+
if (fix && !fix.ok) return 1;
|
|
134
|
+
return result.affected.some((a) => a.severity === 'critical' || a.severity === 'high') ? 1 : 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- guard
|
|
138
|
+
|
|
139
|
+
function guardHelp() {
|
|
140
|
+
console.log(`
|
|
141
|
+
atris sentinel guard, an action guard for coding agents
|
|
142
|
+
|
|
143
|
+
atris sentinel guard install guard claude code in this project
|
|
144
|
+
atris sentinel guard install --global guard every claude code session on this computer
|
|
145
|
+
atris sentinel guard status [--global] is it on and what it stopped today
|
|
146
|
+
atris sentinel guard test run sample commands through the rules
|
|
147
|
+
atris sentinel guard uninstall [--global]
|
|
148
|
+
|
|
149
|
+
it stops shell commands that send secrets out, delete home or root, force
|
|
150
|
+
push to main, drop databases or turn off security. normal curl, git push
|
|
151
|
+
and rm in project folders still run. it uses fixed rules and no model.
|
|
152
|
+
trusted sites for secrets can be added in ~/.atris/sentinel/guard.json.
|
|
153
|
+
|
|
154
|
+
what it promises: it stops the common ways an agent leaks a key or runs a
|
|
155
|
+
destructive command. a network call that carries a secret runs only through
|
|
156
|
+
curl, wget or httpie with options on a short safe list, to trusted sites. a
|
|
157
|
+
command that names claude settings or the guard's files runs only when it
|
|
158
|
+
is a read-only program like cat, grep or git log. what it cannot promise: a program that hides its
|
|
159
|
+
intent can still get past fixed rules. it does not read encoded payloads
|
|
160
|
+
or scripts written to disk and then run. it does not read interpreters
|
|
161
|
+
like python -c either. it is a seatbelt, not a sandbox.
|
|
162
|
+
`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function guardCommand(argv, deps = {}) {
|
|
167
|
+
const sub = argv[0] || 'status';
|
|
168
|
+
const rest = argv.slice(1);
|
|
169
|
+
if (sub === 'help' || argv.includes('--help') || argv.includes('-h')) return guardHelp();
|
|
170
|
+
let opts;
|
|
171
|
+
try { opts = parseFlags(rest, ['global', 'json']); } catch (e) { console.error(` ${e.message}`); return 2; }
|
|
172
|
+
const inst = require('../lib/sentinel-guard-install');
|
|
173
|
+
const env = deps.env || process.env;
|
|
174
|
+
const root = deps.root || process.cwd();
|
|
175
|
+
const where = opts.global ? 'every claude code session on this computer' : `claude code in ${tildePath(path.resolve(root), env.HOME || undefined)}`;
|
|
176
|
+
if (sub === 'install') {
|
|
177
|
+
let st;
|
|
178
|
+
try { st = inst.install({ global: Boolean(opts.global), root, env, atrisBin: deps.atrisBin }); } catch (e) { console.error(` could not install the guard: ${e.message}. your settings were not changed`); return 1; }
|
|
179
|
+
console.log(`\n the sentinel guard is on for ${where}`);
|
|
180
|
+
console.log(` settings: ${tildePath(st.settings, env.HOME || undefined)}`);
|
|
181
|
+
console.log(' it checks each shell command and file edit before it runs. try it with atris sentinel guard test');
|
|
182
|
+
if (st.hooks_off) console.log(' warning: these settings turn all hooks off. the guard will not run until that is removed');
|
|
183
|
+
console.log(' it starts with the next claude code session\n');
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (sub === 'uninstall') {
|
|
187
|
+
try { inst.uninstall({ global: Boolean(opts.global), root, env }); } catch (e) { console.error(` could not remove the guard: ${e.message}. your settings were not changed`); return 1; }
|
|
188
|
+
console.log(` the sentinel guard is off for ${where}`);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
if (sub === 'status') {
|
|
192
|
+
const st = inst.status({ global: Boolean(opts.global), root, env });
|
|
193
|
+
const blocks = inst.recentBlocks({ env });
|
|
194
|
+
const events = require('../lib/sentinel-events');
|
|
195
|
+
const waiting = events.queueSize(env);
|
|
196
|
+
if (opts.json) { console.log(JSON.stringify({ ...st, blocked_today: blocks.length, waiting_to_send: waiting }, null, 2)); return 0; }
|
|
197
|
+
console.log('');
|
|
198
|
+
if (st.installed) console.log(` the sentinel guard is on for ${where}`);
|
|
199
|
+
else if (!st.readable) console.log(` could not read ${tildePath(st.settings, env.HOME || undefined)}. the guard state is unknown`);
|
|
200
|
+
else console.log(` the sentinel guard is off for ${where}. turn it on with atris sentinel guard install${opts.global ? ' --global' : ''}`);
|
|
201
|
+
if (st.installed && !st.current) console.log(' the installed copy is older than this atris. run install again to update it');
|
|
202
|
+
if (st.hooks_off) console.log(' warning: these settings turn all hooks off. the guard does not run');
|
|
203
|
+
console.log(` stopped ${blocks.length} action${blocks.length === 1 ? '' : 's'} in the last day${blocks.length ? `. latest: ${blocks[blocks.length - 1].reason.replace(/^blocked: /, '')}` : ''}`);
|
|
204
|
+
if (waiting) console.log(` ${waiting} event${waiting === 1 ? '' : 's'} waiting to be sent to your dashboard`);
|
|
205
|
+
console.log('');
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
if (sub === 'test') {
|
|
209
|
+
const guard = require('../lib/sentinel-guard');
|
|
210
|
+
const rows = guard.runSamples({ env });
|
|
211
|
+
if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return rows.every((r) => r.ok) ? 0 : 1; }
|
|
212
|
+
console.log('\n sample commands through the sentinel guard rules\n');
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
const mark = r.ok ? ' ' : '!';
|
|
215
|
+
console.log(` ${mark} ${r.got === 'block' ? 'blocked' : 'allowed'} ${r.command}`);
|
|
216
|
+
if (r.got === 'block') console.log(` ${r.reason.replace(/^blocked: /, '')}`);
|
|
217
|
+
}
|
|
218
|
+
const bad = rows.filter((r) => !r.ok);
|
|
219
|
+
const blocked = rows.filter((r) => r.got === 'block').length;
|
|
220
|
+
console.log('');
|
|
221
|
+
console.log(` ${blocked} of ${rows.length} samples blocked, ${rows.length - blocked} allowed`);
|
|
222
|
+
console.log(bad.length ? ` ${bad.length} sample${bad.length === 1 ? '' : 's'} did not do what was expected (marked !)` : ' every sample did what was expected');
|
|
223
|
+
console.log('');
|
|
224
|
+
return bad.length ? 1 : 0;
|
|
225
|
+
}
|
|
226
|
+
console.error(` unknown guard command: ${sub}. use install, uninstall, status or test`);
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------- flush
|
|
231
|
+
|
|
232
|
+
async function flushCommand(argv, deps = {}) {
|
|
233
|
+
const quiet = argv.includes('--quiet');
|
|
234
|
+
const events = require('../lib/sentinel-events');
|
|
235
|
+
const token = tokenFor(deps);
|
|
236
|
+
let res;
|
|
237
|
+
try { res = await events.flushQueue({ token, base: deps.base, env: deps.env || process.env }); } catch (e) { res = { ok: false, reason: e.message, sent: 0 }; }
|
|
238
|
+
if (!quiet) {
|
|
239
|
+
if (res.ok) console.log(` sent ${res.sent} queued event${res.sent === 1 ? '' : 's'} to your atris dashboard`);
|
|
240
|
+
else console.log(` warning: queued events were not sent: ${res.reason}. they stay queued for next time`);
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------- scheduled
|
|
246
|
+
|
|
247
|
+
// What a scheduled run does before the paid review: the two free checks,
|
|
248
|
+
// wrapped in scan_started and scan_finished events. Never throws.
|
|
249
|
+
async function runScheduledFreeChecks(root, deps = {}) {
|
|
250
|
+
const events = require('../lib/sentinel-events');
|
|
251
|
+
const upload = deps.upload !== false;
|
|
252
|
+
const token = upload ? tokenFor(deps) : '';
|
|
253
|
+
const repo = events.repoName(root);
|
|
254
|
+
const started = new Date();
|
|
255
|
+
const say = deps.log || ((m) => console.log(m));
|
|
256
|
+
const out = { agents: null, watch: null, sent: [] };
|
|
257
|
+
const send = async (list) => {
|
|
258
|
+
if (!upload) return { ok: true, skipped: 'no-upload' };
|
|
259
|
+
if (!token) return { ok: false, reason: 'not logged in' };
|
|
260
|
+
try { return await events.postEvents(list, { token, base: deps.base }); } catch (e) { return { ok: false, reason: e.message }; }
|
|
261
|
+
};
|
|
262
|
+
const startEv = events.makeEvent({ kind: 'scan_started', repo, severity: 'info', title: 'Sentinel started its scheduled check of this project.', status: 'resolved', fingerprint: events.fingerprint('scan_started', repo, started.toISOString()), occurredAt: started });
|
|
263
|
+
out.sent.push(await send([startEv]));
|
|
264
|
+
try {
|
|
265
|
+
out.agents = await runAgents(root, { home: false, upload, deps: { ...deps, token } });
|
|
266
|
+
say(` agent setup check: ${out.agents.result.grade}, ${out.agents.result.findings.length} finding${out.agents.result.findings.length === 1 ? '' : 's'}`);
|
|
267
|
+
} catch (e) { say(` warning: the agent setup check did not finish: ${e.message}`); }
|
|
268
|
+
try {
|
|
269
|
+
out.watch = await runWatchCheck(root, { upload, deps: { ...deps, token } });
|
|
270
|
+
if (out.watch.result.offline) say(' warning: the dependency check could not reach the osv database this time');
|
|
271
|
+
else say(` dependency check: ${out.watch.result.affected.length} affected package${out.watch.result.affected.length === 1 ? '' : 's'}`);
|
|
272
|
+
} catch (e) { say(` warning: the dependency check did not finish: ${e.message}`); }
|
|
273
|
+
const agentsOpen = out.agents ? out.agents.result.findings.length : 0;
|
|
274
|
+
const threats = out.watch && !out.watch.result.offline ? out.watch.result.affected.length : 0;
|
|
275
|
+
const bad = (out.agents && out.agents.result.grade === 'fail') || (out.watch && out.watch.result.affected.some((a) => a.severity === 'critical' || a.severity === 'high'));
|
|
276
|
+
const title = events.fillPlain('Sentinel finished its scheduled check. {a} agent setup findings and {t} dependency threats are open.', { a: ['count', String(agentsOpen), 'some'], t: ['count', String(threats), 'some'] });
|
|
277
|
+
const finished = new Date();
|
|
278
|
+
const endEv = events.makeEvent({ kind: 'scan_finished', repo, severity: bad ? 'high' : 'info', title, status: 'resolved', fingerprint: events.fingerprint('scan_finished', repo, started.toISOString()), occurredAt: finished });
|
|
279
|
+
out.sent.push(await send([endEv]));
|
|
280
|
+
if (upload && token) { try { await events.flushQueue({ token, base: deps.base, env: deps.env || process.env }); } catch {} }
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = { agentsCommand, guardCommand, watchCommand, flushCommand, runScheduledFreeChecks, runAgents, runWatchCheck };
|
package/commands/task.js
CHANGED
|
@@ -5963,6 +5963,9 @@ function isUninitializedTaskFolder(cwd = process.cwd()) {
|
|
|
5963
5963
|
|
|
5964
5964
|
function cmdNext(args) {
|
|
5965
5965
|
const root = process.cwd();
|
|
5966
|
+
const { buildAgentNext, printAgentNext } = require('../lib/agent-next');
|
|
5967
|
+
const agent = buildAgentNext({ root, args });
|
|
5968
|
+
if (agent) return printAgentNext(agent, { asJson: wantsJson(args), alias: 'command' });
|
|
5966
5969
|
// Empty folder talks like bare atris. Walk up like the task db so a
|
|
5967
5970
|
// project subdir stays in the room; stop before inheriting /tmp.
|
|
5968
5971
|
// A leftover projection with live tasks still names that work.
|
|
@@ -12244,7 +12247,7 @@ async function run(args) {
|
|
|
12244
12247
|
// `task next --help` is a help request, not a run. Next can open
|
|
12245
12248
|
// the task db or print the desk, so never list or claim just to show usage.
|
|
12246
12249
|
if (sub === 'next' && argsWantHelp(raw.slice(1))) {
|
|
12247
|
-
console.log('Usage: atris task next [--tag <tag>] [--create-next]');
|
|
12250
|
+
console.log('Usage: atris task next [--tag <tag>] [--agent <member>|--human] [--create-next]');
|
|
12248
12251
|
return;
|
|
12249
12252
|
}
|
|
12250
12253
|
// `task show --help` is a help request, not a run. Show can dump
|
|
@@ -12254,13 +12257,17 @@ async function run(args) {
|
|
|
12254
12257
|
return;
|
|
12255
12258
|
}
|
|
12256
12259
|
const result = await runTaskCommand(raw);
|
|
12257
|
-
const
|
|
12260
|
+
const readOnlyAgentNext = sub === 'next' && require('../lib/agent-next').agentCaller(raw.slice(1));
|
|
12261
|
+
const skipsRender = hasFlag(raw, '--dry-run') || readOnlyAgentNext;
|
|
12258
12262
|
if (MUTATING_TASK_COMMANDS.has(sub) && !skipsRender) autoRenderTodoFromDb();
|
|
12259
12263
|
return result;
|
|
12260
12264
|
}
|
|
12261
12265
|
|
|
12262
12266
|
module.exports = {
|
|
12263
12267
|
run,
|
|
12268
|
+
taskQueueScopeFromArgs,
|
|
12269
|
+
normalizeTaskQueueScope,
|
|
12270
|
+
filterTasksByScope,
|
|
12264
12271
|
chooseTaskOwner,
|
|
12265
12272
|
readTeamMembers,
|
|
12266
12273
|
createTaskApiServer,
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A read-only recommendation. Existing task commands own every transition.
|
|
4
|
+
const { readFlag } = require('./arg-parser');
|
|
5
|
+
const { workerEnvMarker } = require('./task-core');
|
|
6
|
+
const { autoJobForMember } = require('./member-engine');
|
|
7
|
+
const { skipReason, heldForPerson, taskCheck } = require('./member-task-pick');
|
|
8
|
+
const { buildNextWorkContext } = require('./next-work-context');
|
|
9
|
+
const reviewIntegrity = require('./review-integrity');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
|
|
12
|
+
const text = (value) => typeof value === 'string' ? value.trim() : '';
|
|
13
|
+
const quote = (value) => /^[A-Za-z0-9_.:-]+$/.test(String(value))
|
|
14
|
+
? String(value) : "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
15
|
+
const identifier = (value) => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,120}$/.test(value) ? value : null;
|
|
16
|
+
const ref = (task) => identifier(task?.display_id) || identifier(task?.id) || '';
|
|
17
|
+
const certified = (task) => task?.review?.agent_certified === true || task?.metadata?.agent_certified === true;
|
|
18
|
+
const ownerField = (value) => typeof value === 'string' && value.length <= 80 && !/[\u0000-\u001f]/.test(value) ? value || null : null;
|
|
19
|
+
const knownStatus = (value) => ['open', 'claimed', 'review', 'done', 'failed', 'archived', 'blocked'].includes(value) ? value : null;
|
|
20
|
+
const sourceKind = (value) => ['database', 'mixed', 'projection', 'none'].includes(value) ? value : 'none';
|
|
21
|
+
|
|
22
|
+
function agentCaller(args = [], env = process.env) {
|
|
23
|
+
if (args.includes('--human')) return null;
|
|
24
|
+
const explicit = args.some((arg) => arg === '--agent' || arg.startsWith('--agent='));
|
|
25
|
+
if (!explicit && env.ATRIS_AGENT_PROOF_ONLY === '0') return null;
|
|
26
|
+
if (!explicit && !workerEnvMarker(env) && !text(env.ATRIS_AGENT_ID)) return null;
|
|
27
|
+
const named = text(readFlag(args, '--agent'));
|
|
28
|
+
const as = text(readFlag(args, '--as'));
|
|
29
|
+
const member = named || as || text(env.ATRIS_AGENT_ID) || 'builder';
|
|
30
|
+
const error = named && as && named !== as ? 'conflicting_owner'
|
|
31
|
+
: !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(member) ? 'invalid_owner' : null;
|
|
32
|
+
return { kind: 'agent', member, error };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkFor(task) {
|
|
36
|
+
const metadata = task?.metadata || {};
|
|
37
|
+
const command = text(metadata.verify) || text(metadata.proof_needed);
|
|
38
|
+
return {
|
|
39
|
+
command,
|
|
40
|
+
runnable: Boolean(command) && command.length <= 600
|
|
41
|
+
&& taskCheck({ metadata: { verify: command } }).runnable,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function planningNeeds(task) {
|
|
46
|
+
const meta = task.metadata || {};
|
|
47
|
+
const needs = [];
|
|
48
|
+
if (!checkFor(task).runnable) needs.push('runnable_check');
|
|
49
|
+
if (!(text(meta.task_goal) || text(meta.goal_objective) || text(meta.objective) || text(meta.stage_goal))) needs.push('goal');
|
|
50
|
+
if (!(text(meta.exit_condition) || text(meta.done_looks_like))) needs.push('exit_condition');
|
|
51
|
+
if (!text(meta.first_move)) needs.push('first_move');
|
|
52
|
+
if (!(meta.planned_at || meta.stage_plan_recorded_at || meta.stage === 'plan' || meta.stage === 'do')) needs.push('recorded_plan');
|
|
53
|
+
return needs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recommendAgentWork({ tasks = [], member, job = 'build', taskSource = 'database', taskId = '', isAuthor = reviewIntegrity.isTaskAuthor } = {}) {
|
|
57
|
+
const pending = tasks.filter((task) => taskSource === 'database' && identifier(task?.id)
|
|
58
|
+
&& task?.status === 'review' && certified(task))
|
|
59
|
+
.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))
|
|
60
|
+
.slice(0, 5).map((task) => ({
|
|
61
|
+
task_id: task.id || null, reference: ref(task) || null,
|
|
62
|
+
reason: 'human_acceptance', human_only: true,
|
|
63
|
+
command: ref(task) ? 'atris task accept ' + quote(ref(task)) : null,
|
|
64
|
+
}));
|
|
65
|
+
const result = (task, action, reason, needs = []) => ({
|
|
66
|
+
task, action, reason, needs, human_pending: pending,
|
|
67
|
+
});
|
|
68
|
+
const bound = taskId ? tasks.find((task) => task.id === taskId || ref(task) === taskId) : null;
|
|
69
|
+
if (taskId && !bound) return result(null, 'blocked', 'The requested task is not in current workspace evidence.', ['current_task']);
|
|
70
|
+
if (bound && taskSource !== 'database') {
|
|
71
|
+
return result(bound, 'discover', 'Confirm the bound task state before relying on this snapshot.', ['authoritative_task_state']);
|
|
72
|
+
}
|
|
73
|
+
const rows = taskId ? [bound] : tasks;
|
|
74
|
+
const eligible = rows.filter((task) => {
|
|
75
|
+
if (!task || typeof task !== 'object') return false;
|
|
76
|
+
// An absent or unusable check still permits planning. It never permits
|
|
77
|
+
// claiming or executing; ownership and human holds remain unchanged.
|
|
78
|
+
const probe = { ...task, metadata: { ...task.metadata, verify: '' } };
|
|
79
|
+
return !skipReason(probe, job, { member, recoverClaimed: true });
|
|
80
|
+
}).sort((a, b) => Number(b.status === 'claimed') - Number(a.status === 'claimed')
|
|
81
|
+
|| Number(Boolean(text(b.metadata?.assigned_to))) - Number(Boolean(text(a.metadata?.assigned_to)))
|
|
82
|
+
|| Number(a.created_at || 0) - Number(b.created_at || 0)
|
|
83
|
+
|| String(a.id || '').localeCompare(String(b.id || '')));
|
|
84
|
+
let selected = eligible[0] || null;
|
|
85
|
+
if (!selected && job === 'review') {
|
|
86
|
+
selected = rows.find((task) => task?.status === 'review' && !certified(task)
|
|
87
|
+
&& !heldForPerson(task) && !isAuthor(task, member)) || null;
|
|
88
|
+
if (selected) {
|
|
89
|
+
if (taskSource !== 'database') return result(selected, 'discover', 'Confirm the current task state before reviewing this snapshot.', ['authoritative_task_state']);
|
|
90
|
+
return result(selected, 'review', 'An independent reviewer can check the finished work.');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!selected) {
|
|
94
|
+
if (bound) return result(bound, 'blocked', 'This member has no permitted step on the requested task. Inspect its current contract.', ['eligible_task']);
|
|
95
|
+
return result(null, 'discover', 'No eligible work is recorded. Inspect the goal and prior results before proposing one verifiable change.', ['eligible_task']);
|
|
96
|
+
}
|
|
97
|
+
if (taskSource !== 'database') {
|
|
98
|
+
return result(selected, 'discover', 'Confirm current task evidence before claiming or changing this snapshot.', ['authoritative_task_state']);
|
|
99
|
+
}
|
|
100
|
+
const needs = planningNeeds(selected);
|
|
101
|
+
if (needs.length) return result(selected, 'plan', 'Inspect this task and record its goal, exit, first move and runnable check before starting it.', needs);
|
|
102
|
+
return selected.status === 'claimed'
|
|
103
|
+
? result(selected, 'continue', 'Continue the owned work and collect real proof before marking it ready.')
|
|
104
|
+
: result(selected, 'claim', 'This owned or matching task has a recorded plan and runnable check.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recommendationCommand(decision, member) {
|
|
108
|
+
if (decision.action === 'discover') return null;
|
|
109
|
+
const taskRef = ref(decision.task);
|
|
110
|
+
if (!taskRef) return null;
|
|
111
|
+
if (decision.action === 'claim') return 'atris task claim ' + quote(taskRef) + ' --as ' + quote(member);
|
|
112
|
+
if (decision.action === 'review') return 'atris task review-chat ' + quote(taskRef) + ' --as ' + quote(member);
|
|
113
|
+
return 'atris task page ' + quote(taskRef) + ' --json';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function boundedPacket(payload) {
|
|
117
|
+
payload.output_budget = { max_bytes: 16000, limited: false };
|
|
118
|
+
// Include the longest alias, pretty formatting and the CLI newline.
|
|
119
|
+
const bytes = () => Buffer.byteLength(JSON.stringify({ ...payload, next_action: payload.command }, null, 2) + '\n');
|
|
120
|
+
const limited = () => {
|
|
121
|
+
payload.output_budget.limited = true;
|
|
122
|
+
if (Array.isArray(payload.context.gaps) && !payload.context.gaps.includes('response_budget_limited')) {
|
|
123
|
+
payload.context.gaps.push('response_budget_limited');
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
while (bytes() > 16000 && payload.human_pending.length > 1) {
|
|
127
|
+
payload.human_pending.pop();
|
|
128
|
+
limited();
|
|
129
|
+
}
|
|
130
|
+
for (const key of ['candidates', 'recent_outcomes', 'parts']) {
|
|
131
|
+
while (bytes() > 16000 && Array.isArray(payload.context[key]) && payload.context[key].length) {
|
|
132
|
+
payload.context[key].pop();
|
|
133
|
+
limited();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (bytes() > 16000) {
|
|
137
|
+
limited();
|
|
138
|
+
payload.action = payload.action === 'blocked' ? 'blocked' : 'discover';
|
|
139
|
+
payload.command = null;
|
|
140
|
+
payload.scope = {};
|
|
141
|
+
payload.reason = 'The evidence exceeds the response budget. Inspect current sources before acting.';
|
|
142
|
+
payload.needs = [...new Set([...payload.needs, 'bounded_evidence'])];
|
|
143
|
+
payload.human_pending = [];
|
|
144
|
+
payload.context = {
|
|
145
|
+
schema: 'atris.next_work_context.v1', task_source: sourceKind(payload.context.task_source),
|
|
146
|
+
goal: null, parts: [], candidates: [], recent_outcomes: [], sources: [],
|
|
147
|
+
gaps: ['response_budget_limited'], next_step: { kind: 'discover', reason: payload.reason, check: null },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return payload;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildAgentNext({ root = process.cwd(), args = [], env = process.env, snapshot, tasks, scope = {}, evidence } = {}) {
|
|
154
|
+
const caller = agentCaller(args, env);
|
|
155
|
+
if (!caller) return null;
|
|
156
|
+
const taskDb = require('./task-db');
|
|
157
|
+
const workspaceRoot = taskDb.workspaceRoot(root);
|
|
158
|
+
const taskId = text(readFlag(args, '--task'));
|
|
159
|
+
const state = snapshot || require('./first-minute').loadLocalTaskState(workspaceRoot, {
|
|
160
|
+
includeAllActive: true, taskIds: taskId ? [taskId] : [],
|
|
161
|
+
});
|
|
162
|
+
const taskSource = sourceKind(state.source);
|
|
163
|
+
let rows = tasks || state.tasks;
|
|
164
|
+
let queryScope = scope;
|
|
165
|
+
if (!tasks && args.some((arg) => /^--(?:goal[-_]id|tag|status|review[-_]state)(?:=|$)/.test(arg))) {
|
|
166
|
+
// Reuse the task surface's existing filters, including goal/review scope.
|
|
167
|
+
// Loaded only for scoped calls, after command modules have initialized.
|
|
168
|
+
const { taskQueueScopeFromArgs, normalizeTaskQueueScope, filterTasksByScope } = require('../commands/task');
|
|
169
|
+
const queryArgs = args.flatMap((arg) => /^--[^=]+=/.test(arg)
|
|
170
|
+
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg]);
|
|
171
|
+
queryScope = normalizeTaskQueueScope(taskQueueScopeFromArgs(queryArgs));
|
|
172
|
+
rows = filterTasksByScope(rows, queryScope);
|
|
173
|
+
}
|
|
174
|
+
const scopeFits = Buffer.byteLength(JSON.stringify(queryScope)) <= 2048;
|
|
175
|
+
const job = caller.error ? 'build' : autoJobForMember(caller.member, '');
|
|
176
|
+
let authorshipAvailable = true;
|
|
177
|
+
const isAuthor = job === 'review' && taskSource === 'database' ? (task, actor) => {
|
|
178
|
+
try {
|
|
179
|
+
if (!fs.existsSync(taskDb.getDbPath())) throw new Error('task history unavailable');
|
|
180
|
+
return taskDb.isAuthorOfTask(taskDb.open(), task, actor);
|
|
181
|
+
} catch {
|
|
182
|
+
authorshipAvailable = false;
|
|
183
|
+
return true; // Unknown authorship cannot establish an independent reviewer.
|
|
184
|
+
}
|
|
185
|
+
} : reviewIntegrity.isTaskAuthor;
|
|
186
|
+
const decision = caller.error
|
|
187
|
+
? { task: null, action: 'blocked', reason: 'Name one valid functional owner for this agent.', needs: ['functional_owner'], human_pending: [] }
|
|
188
|
+
: !scopeFits ? { task: null, action: 'blocked', reason: 'Keep the requested scope small enough to inspect.', needs: ['bounded_scope'], human_pending: [] }
|
|
189
|
+
: recommendAgentWork({ tasks: rows, member: caller.member, job, taskSource, taskId, isAuthor });
|
|
190
|
+
if (!authorshipAvailable && decision.action !== 'review') {
|
|
191
|
+
decision.needs.push('authorship_history');
|
|
192
|
+
decision.reason = 'Confirm the task authorship history before assigning an independent review.';
|
|
193
|
+
}
|
|
194
|
+
if (decision.task && !identifier(decision.task.id)) {
|
|
195
|
+
decision.action = 'discover';
|
|
196
|
+
decision.needs.push('stable_task_identity');
|
|
197
|
+
decision.reason = 'Confirm a stable task identity before using this evidence.';
|
|
198
|
+
}
|
|
199
|
+
if (decision.task && [decision.task.claimed_by, decision.task.metadata?.assigned_to]
|
|
200
|
+
.some((value) => value != null && value !== '' && ownerField(value) === null)) {
|
|
201
|
+
decision.action = 'discover';
|
|
202
|
+
decision.needs.push('valid_task_ownership');
|
|
203
|
+
decision.reason = 'Confirm the recorded ownership before relying on this task evidence.';
|
|
204
|
+
}
|
|
205
|
+
if (decision.task && !knownStatus(decision.task.status)) {
|
|
206
|
+
decision.action = 'discover';
|
|
207
|
+
decision.needs.push('current_task_status');
|
|
208
|
+
}
|
|
209
|
+
const command = recommendationCommand(decision, caller.member);
|
|
210
|
+
const context = evidence || buildNextWorkContext({
|
|
211
|
+
root: workspaceRoot, tasks: rows, selected: decision.task, owner: caller.member,
|
|
212
|
+
taskSource, generatedAt: state.generated_at,
|
|
213
|
+
});
|
|
214
|
+
context.task_source = taskSource;
|
|
215
|
+
// The existing evidence packet keeps its own factual gaps. The action is
|
|
216
|
+
// the owner's next permitted step, not a claim that a check has passed.
|
|
217
|
+
context.next_step.kind = decision.action === 'plan' ? 'discover'
|
|
218
|
+
: ['claim', 'continue'].includes(decision.action) ? 'execute'
|
|
219
|
+
: decision.action === 'review' ? 'review' : decision.action === 'discover' ? 'discover'
|
|
220
|
+
: ['human_decision', 'wait'].includes(context.next_step.kind) ? context.next_step.kind : 'discover';
|
|
221
|
+
context.next_step.reason = decision.reason;
|
|
222
|
+
const task = decision.task;
|
|
223
|
+
return boundedPacket({
|
|
224
|
+
schema: 'atris.agent_next.v1', ok: !caller.error && scopeFits, status: decision.action === 'blocked' ? 'blocked' : 'ok',
|
|
225
|
+
actor: { kind: 'agent', member: caller.error ? null : caller.member },
|
|
226
|
+
owner: caller.error ? null : caller.member, action: decision.action,
|
|
227
|
+
task_id: identifier(task?.id), reference: task ? ref(task) || null : null,
|
|
228
|
+
command, reason: decision.reason, needs: decision.needs,
|
|
229
|
+
task: task ? {
|
|
230
|
+
id: identifier(task.id), display_id: identifier(task.display_id),
|
|
231
|
+
title: text(task.title).slice(0, 240), status: knownStatus(task.status),
|
|
232
|
+
claimed_by: ownerField(task.claimed_by), assigned_to: ownerField(task.metadata?.assigned_to),
|
|
233
|
+
} : null,
|
|
234
|
+
human_pending: decision.human_pending, context, scope: scopeFits ? queryScope : {},
|
|
235
|
+
safety: { read_only: true, claims_work: false, human_accept: false, awards_xp: false },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function printAgentNext(payload, { asJson = false, alias = 'next_action', log = console.log } = {}) {
|
|
240
|
+
if (asJson) {
|
|
241
|
+
log(JSON.stringify({ ...payload, [alias]: payload.command }, null, 2));
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
const title = payload.task?.title;
|
|
245
|
+
const message = payload.action === 'plan' && title ? 'plan ' + title + ' and define its check.'
|
|
246
|
+
: payload.action === 'claim' && title ? title + ' has a plan and a check.'
|
|
247
|
+
: payload.action === 'continue' && title ? 'continue ' + title + '.'
|
|
248
|
+
: payload.reason;
|
|
249
|
+
log(message.replace(/^[A-Z]/, (letter) => letter.toLowerCase()));
|
|
250
|
+
if (payload.human_pending.length) log('\nhuman approval is still pending; this step does not accept that work.');
|
|
251
|
+
if (payload.command) log('\nnext: ' + payload.command);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { agentCaller, planningNeeds, recommendAgentWork, buildAgentNext, printAgentNext };
|
package/lib/campaign-keep.js
CHANGED
|
@@ -35,6 +35,7 @@ function cronSchedule(seconds) {
|
|
|
35
35
|
if (minutes === 1) return '* * * * *';
|
|
36
36
|
if (minutes < 60) return `*/${minutes} * * * *`;
|
|
37
37
|
const hours = Math.round(minutes / 60);
|
|
38
|
+
if (hours >= 168) return '0 0 * * 0';
|
|
38
39
|
return hours >= 24 ? '0 0 * * *' : `0 */${hours} * * *`;
|
|
39
40
|
}
|
|
40
41
|
|
package/lib/first-minute.js
CHANGED
|
@@ -177,7 +177,7 @@ function titlesFromTodoSection(root, section) {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
function loadLocalTaskState(root = process.cwd(), { taskIds = [] } = {}) {
|
|
180
|
+
function loadLocalTaskState(root = process.cwd(), { taskIds = [], includeAllActive = false } = {}) {
|
|
181
181
|
let dbRows = [];
|
|
182
182
|
let lookup = () => null;
|
|
183
183
|
let databaseRead = false;
|
|
@@ -187,23 +187,30 @@ function loadLocalTaskState(root = process.cwd(), { taskIds = [] } = {}) {
|
|
|
187
187
|
if (!fs.existsSync(taskDb.getDbPath())) throw new Error('no task db yet');
|
|
188
188
|
const db = taskDb.open();
|
|
189
189
|
const workspaceRoot = taskDb.workspaceRoot(root);
|
|
190
|
-
|
|
190
|
+
// The visible window must not become a new numbering universe. Task
|
|
191
|
+
// commands resolve these references against the complete workspace.
|
|
192
|
+
const refRows = taskDb.listTasks(db, { workspaceRoot });
|
|
193
|
+
const rows = includeAllActive
|
|
194
|
+
? refRows.filter((row) => ['open', 'claimed', 'review'].includes(row.status))
|
|
195
|
+
: taskDb.listTasks(db, { workspaceRoot, limit: 200 });
|
|
191
196
|
// Busy queues must not crowd the last outcomes out of the handoff.
|
|
192
197
|
const recent = ['done', 'failed'].flatMap((status) => taskDb.listTasks(db, { workspaceRoot, status, limit: 3, orderBy: 'updated_at' }));
|
|
193
198
|
const seen = new Set(rows.map((row) => row.id));
|
|
194
199
|
rows.push(...recent.filter((row) => !seen.has(row.id)));
|
|
195
|
-
if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows);
|
|
200
|
+
if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows, refRows);
|
|
196
201
|
databaseRead = true;
|
|
197
202
|
lookup = (id) => {
|
|
198
203
|
const row = taskDb.getTask(db, id);
|
|
199
204
|
// A foreign id in a damaged projection is not this workspace's work.
|
|
200
205
|
if (row && !taskDb.rowInWorkspace(row, workspaceRoot)) return false;
|
|
201
|
-
return row ? taskDb.withTaskDisplayRefs([row])[0] : null;
|
|
206
|
+
return row ? taskDb.withTaskDisplayRefs([row], refRows)[0] : null;
|
|
202
207
|
};
|
|
203
208
|
// A shortlist is an output budget, not authority to invent a bound job.
|
|
204
209
|
for (const id of taskIds.slice(0, 8)) {
|
|
205
|
-
if (dbRows.some((row) => row.id === id)) continue;
|
|
206
|
-
const row = lookup(id)
|
|
210
|
+
if (dbRows.some((row) => row.id === id || (includeAllActive && row.display_id === id))) continue;
|
|
211
|
+
const row = lookup(id) || (includeAllActive
|
|
212
|
+
? taskDb.withTaskDisplayRefs(refRows, refRows).find((item) => item.display_id === id)
|
|
213
|
+
: null);
|
|
207
214
|
if (row) dbRows.push(row);
|
|
208
215
|
}
|
|
209
216
|
} catch {
|