atris 3.42.0 → 3.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/atris/skills/design/SKILL.md +7 -1
- package/atris/skills/engines/SKILL.md +44 -13
- package/atris/team/customer-lead/MEMBER.md +45 -0
- package/atris/team/customer-lead/SOUL.md +33 -0
- package/atris/team/customer-lead/START_HERE.md +7 -0
- package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
- package/atris/team/improver/MEMBER.md +33 -0
- package/bin/atris.js +37 -4
- package/commands/autoland.js +15 -1
- package/commands/caretaker.js +303 -0
- package/commands/clean.js +76 -0
- package/commands/engine-watch.js +212 -0
- package/commands/engine.js +99 -11
- package/commands/founder.js +304 -0
- package/commands/human-missions.js +844 -0
- package/commands/init.js +16 -7
- package/commands/lesson.js +178 -4
- package/commands/mission.js +124 -69
- package/commands/slop.js +34 -3
- package/commands/task.js +51 -4
- package/commands/team.js +329 -13
- package/commands/verify.js +99 -6
- package/commands/worktree.js +119 -4
- package/lib/auto-accept-certified.js +302 -0
- package/lib/cloud-mission.js +59 -2
- package/lib/conductor-artifacts.js +1 -1
- package/lib/dispatch-scout.js +383 -0
- package/lib/engine-ask.js +645 -0
- package/lib/engine-job-lifecycle.js +65 -0
- package/lib/engine-receipt-sweep.js +98 -0
- package/lib/engine-registry.js +2 -2
- package/lib/engine-validate.js +374 -0
- package/lib/fleet.js +459 -106
- package/lib/known-commands.js +2 -2
- package/lib/lesson-ledger.js +84 -0
- package/lib/member-alive.js +2 -2
- package/lib/policy-lessons.js +70 -0
- package/lib/receipt-evidence.js +56 -1
- package/lib/runner-command.js +1 -1
- package/lib/secret-gateway.js +588 -0
- package/lib/team-presence.js +13 -1
- package/lib/voice-gate.js +6 -0
- package/lib/wish-audit.js +5 -205
- package/lib/wish-delegate.js +5 -2
- package/package.json +6 -1
package/commands/worktree.js
CHANGED
|
@@ -12,8 +12,8 @@ const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
|
12
12
|
const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
|
|
13
13
|
const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
|
|
14
14
|
|
|
15
|
-
function runGit(args, { cwd = process.cwd(), check = true } = {}) {
|
|
16
|
-
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
15
|
+
function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
|
|
16
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout });
|
|
17
17
|
if (check && result.status !== 0) {
|
|
18
18
|
const msg = (result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim();
|
|
19
19
|
throw new Error(msg);
|
|
@@ -157,6 +157,95 @@ function listWorktrees(root = repoRoot()) {
|
|
|
157
157
|
return parseWorktrees(runGit(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
|
|
161
|
+
// same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
|
|
162
|
+
// direct `worktree start` had no pre-check beyond the target path existing.
|
|
163
|
+
const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
|
|
164
|
+
const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
165
|
+
const FLIGHT_STOPWORDS = new Set([
|
|
166
|
+
'fix', 'add', 'update', 'make', 'change', 'new', 'task', 'work', 'repo',
|
|
167
|
+
'cli', 'atris', 'backend', 'the', 'a', 'an', 'and', 'for', 'to', 'of', 'in', 'on',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
function flightStampMs(stampText) {
|
|
171
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(stampText || ''));
|
|
172
|
+
if (!m) return null;
|
|
173
|
+
const [, year, month, day, hour, minute, second] = m.map(Number);
|
|
174
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null;
|
|
175
|
+
return Date.UTC(year, month - 1, day, hour, minute, second);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function taskTokens(value) {
|
|
179
|
+
return String(value || '')
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.split(/[^a-z0-9]+/)
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.filter((token) => !FLIGHT_STOPWORDS.has(token));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// codex/<owner>-<task-slug>-<YYYYMMDD-HHMMSS>: drop the owner segment and the
|
|
187
|
+
// stamp, and what is left is the task the flight is working on.
|
|
188
|
+
function parseAgentFlightName(name) {
|
|
189
|
+
const text = String(name || '').trim().replace(/^refs\/heads\//, '');
|
|
190
|
+
const match = AGENT_FLIGHT_NAME_PATTERN.exec(text);
|
|
191
|
+
if (!match) return null;
|
|
192
|
+
const stampMs = flightStampMs(match[2]);
|
|
193
|
+
if (stampMs === null) return null;
|
|
194
|
+
const segments = match[1].split('-').filter(Boolean);
|
|
195
|
+
if (segments.length < 2) return null;
|
|
196
|
+
return {
|
|
197
|
+
name: text,
|
|
198
|
+
owner: segments[0],
|
|
199
|
+
taskSlug: segments.slice(1).join('-'),
|
|
200
|
+
stamp: match[2],
|
|
201
|
+
stampMs,
|
|
202
|
+
tokens: taskTokens(segments.slice(1).join('-')),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function remoteAgentBranchNames(root) {
|
|
207
|
+
// One ls-remote, best effort: offline dispatchers still get the local view.
|
|
208
|
+
const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false, timeout: 10000 });
|
|
209
|
+
if (result.status !== 0) return [];
|
|
210
|
+
return String(result.stdout || '')
|
|
211
|
+
.split(/\r?\n/)
|
|
212
|
+
.map((line) => (line.split(/\s+/)[1] || '').replace(/^refs\/heads\//, '').trim())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs = FLIGHT_WINDOW_MS } = {}) {
|
|
217
|
+
const nowMs = now.getTime();
|
|
218
|
+
const flights = new Map();
|
|
219
|
+
const add = (name, kind) => {
|
|
220
|
+
const flight = parseAgentFlightName(name);
|
|
221
|
+
if (!flight) return;
|
|
222
|
+
// Branch age comes from the stamp in the name, never a git log per branch.
|
|
223
|
+
if (kind !== 'worktree' && nowMs - flight.stampMs > windowMs) return;
|
|
224
|
+
const existing = flights.get(flight.name);
|
|
225
|
+
if (existing && existing.kind === 'worktree') return;
|
|
226
|
+
flights.set(flight.name, { ...flight, kind });
|
|
227
|
+
};
|
|
228
|
+
for (const wt of listWorktrees(root)) add(wt.branch, 'worktree');
|
|
229
|
+
const local = runGit(['branch', '--format=%(refname:short)'], { cwd: root, check: false });
|
|
230
|
+
if (local.status === 0) {
|
|
231
|
+
for (const name of String(local.stdout || '').split(/\r?\n/)) add(name, 'branch');
|
|
232
|
+
}
|
|
233
|
+
for (const name of remoteAgentBranchNames(root)) add(name, 'branch');
|
|
234
|
+
return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collidingFlights(flights, tokens) {
|
|
238
|
+
const wanted = new Set(tokens);
|
|
239
|
+
if (!wanted.size) return [];
|
|
240
|
+
return flights.filter((flight) => flight.tokens.some((token) => wanted.has(token)));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function describeFlightAge(flight, nowMs = Date.now()) {
|
|
244
|
+
const minutes = Math.max(0, Math.round((nowMs - flight.stampMs) / 60000));
|
|
245
|
+
if (minutes < 60) return `${minutes}m old`;
|
|
246
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m old`;
|
|
247
|
+
}
|
|
248
|
+
|
|
160
249
|
function refExists(root, ref) {
|
|
161
250
|
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], { cwd: root, check: false }).status === 0;
|
|
162
251
|
}
|
|
@@ -408,13 +497,33 @@ function startWorktree(args) {
|
|
|
408
497
|
const owner = member || agent;
|
|
409
498
|
const task = readFlag(args, '--task');
|
|
410
499
|
if (!owner || !task) {
|
|
411
|
-
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim]');
|
|
500
|
+
console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force]');
|
|
412
501
|
return 2;
|
|
413
502
|
}
|
|
414
503
|
const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
|
|
415
504
|
if (memberFile && !fs.existsSync(memberFile)) {
|
|
416
505
|
console.error(`warning: no member persona at ${path.relative(root, memberFile)}`);
|
|
417
506
|
}
|
|
507
|
+
|
|
508
|
+
const force = hasFlag(args, '--force');
|
|
509
|
+
const now = new Date();
|
|
510
|
+
const flights = inFlightAgentFlights({ root, now });
|
|
511
|
+
const active = flights.filter((flight) => flight.kind === 'worktree');
|
|
512
|
+
const repoName = path.basename(findPrimaryRoot(root));
|
|
513
|
+
console.log(`flights: ${active.length} active agent ${active.length === 1 ? 'worktree' : 'worktrees'} for ${repoName}`);
|
|
514
|
+
const collisions = collidingFlights(flights, taskTokens(slugify(task, 'task', 36)));
|
|
515
|
+
if (collisions.length) {
|
|
516
|
+
const label = force ? 'warning' : 'refusing';
|
|
517
|
+
for (const flight of collisions) {
|
|
518
|
+
console.error(`${label}: overlapping flight ${flight.name} (${describeFlightAge(flight, now.getTime())})`);
|
|
519
|
+
}
|
|
520
|
+
console.error(`${label}: another flight may already be doing this work`);
|
|
521
|
+
if (!force) {
|
|
522
|
+
console.error('refusing: pass --force to start anyway');
|
|
523
|
+
return 2;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
418
527
|
let created;
|
|
419
528
|
try {
|
|
420
529
|
created = createAgentWorktree({
|
|
@@ -912,7 +1021,8 @@ function help() {
|
|
|
912
1021
|
console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup>');
|
|
913
1022
|
console.log('');
|
|
914
1023
|
console.log(' atris worktree guide');
|
|
915
|
-
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim]');
|
|
1024
|
+
console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force]');
|
|
1025
|
+
console.log(' --force start even when another recent flight shares a task word');
|
|
916
1026
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
917
1027
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
918
1028
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
@@ -946,15 +1056,20 @@ function worktreeCommand(args = []) {
|
|
|
946
1056
|
|
|
947
1057
|
module.exports = {
|
|
948
1058
|
branchName,
|
|
1059
|
+
collidingFlights,
|
|
949
1060
|
createAgentWorktree,
|
|
950
1061
|
deniedLaneForShip,
|
|
951
1062
|
createOrFindPr,
|
|
952
1063
|
cleanupWorktrees,
|
|
953
1064
|
defaultStartBase,
|
|
1065
|
+
describeFlightAge,
|
|
1066
|
+
flightStampMs,
|
|
954
1067
|
listWorktrees,
|
|
1068
|
+
parseAgentFlightName,
|
|
955
1069
|
parseWorktrees,
|
|
956
1070
|
normalizeTargetRef,
|
|
957
1071
|
slugify,
|
|
1072
|
+
taskTokens,
|
|
958
1073
|
statusCounts,
|
|
959
1074
|
swarloClaim,
|
|
960
1075
|
worktreeCommand,
|
|
@@ -7,6 +7,7 @@ const { taskProofState, taskProofExecutionState } = require('./task-proof');
|
|
|
7
7
|
const { extractReceiptEvidence } = require('./receipt-evidence');
|
|
8
8
|
const reviewIntegrity = require('./review-integrity');
|
|
9
9
|
const { computeTrustTier } = require('./trust-tiers');
|
|
10
|
+
const { policyLessonsForFiles, readPolicyLessons } = require('./policy-lessons');
|
|
10
11
|
|
|
11
12
|
const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
|
|
12
13
|
// Kept for compat with older callers/tests; the pass-count landing lane it
|
|
@@ -620,6 +621,298 @@ function hygieneBlockResult(task, ref) {
|
|
|
620
621
|
};
|
|
621
622
|
}
|
|
622
623
|
|
|
624
|
+
const CANDIDATE_PATH_KEYS = [
|
|
625
|
+
'files',
|
|
626
|
+
'paths',
|
|
627
|
+
'touched_files',
|
|
628
|
+
'touchedFiles',
|
|
629
|
+
'changed_files',
|
|
630
|
+
'changedFiles',
|
|
631
|
+
'modified_files',
|
|
632
|
+
'modifiedFiles',
|
|
633
|
+
'artifacts',
|
|
634
|
+
'artifact_paths',
|
|
635
|
+
'artifactPaths',
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
function candidatePathValues(value) {
|
|
639
|
+
if (!value) return [];
|
|
640
|
+
if (Array.isArray(value)) return value.flatMap(candidatePathValues);
|
|
641
|
+
if (typeof value === 'object') {
|
|
642
|
+
for (const key of ['path', 'file', 'filename']) {
|
|
643
|
+
if (typeof value[key] === 'string') return [value[key]];
|
|
644
|
+
}
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
if (typeof value !== 'string') return [];
|
|
648
|
+
return value.split(/[\n,]+/).map((entry) => entry.trim()).filter(Boolean);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function declaredCandidatePathValues(task) {
|
|
652
|
+
const metadata = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
|
|
653
|
+
const review = task && task.review && typeof task.review === 'object' ? task.review : {};
|
|
654
|
+
const sources = [
|
|
655
|
+
metadata,
|
|
656
|
+
metadata.diff_stats,
|
|
657
|
+
metadata.diffStats,
|
|
658
|
+
metadata.git_diff_stats,
|
|
659
|
+
metadata.gitDiffStats,
|
|
660
|
+
metadata.change_stats,
|
|
661
|
+
metadata.changeStats,
|
|
662
|
+
metadata.result_trace,
|
|
663
|
+
review.result,
|
|
664
|
+
review.result_trace,
|
|
665
|
+
].filter((source) => source && typeof source === 'object');
|
|
666
|
+
const values = [];
|
|
667
|
+
for (const source of sources) {
|
|
668
|
+
for (const key of CANDIDATE_PATH_KEYS) values.push(...candidatePathValues(source[key]));
|
|
669
|
+
}
|
|
670
|
+
for (const event of Array.isArray(task && task.events) ? task.events : []) {
|
|
671
|
+
const payload = event && event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
672
|
+
const trace = payload.result_trace && typeof payload.result_trace === 'object' ? payload.result_trace : null;
|
|
673
|
+
if (!trace) continue;
|
|
674
|
+
for (const key of CANDIDATE_PATH_KEYS) values.push(...candidatePathValues(trace[key]));
|
|
675
|
+
}
|
|
676
|
+
return [...new Set(values)];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function candidateWorkspaceRoot(task) {
|
|
680
|
+
const workspace = path.resolve(task.workspace_root || process.cwd());
|
|
681
|
+
const metadata = task.metadata || {};
|
|
682
|
+
const declared = metadata.worktree_path
|
|
683
|
+
|| metadata.worktreePath
|
|
684
|
+
|| metadata.worktree?.path
|
|
685
|
+
|| task.worktree_path
|
|
686
|
+
|| task.worktree?.path;
|
|
687
|
+
if (!declared) return workspace;
|
|
688
|
+
const target = path.resolve(String(declared));
|
|
689
|
+
const allowedRoot = parentArenaDir(workspace);
|
|
690
|
+
try {
|
|
691
|
+
if (isInsidePath(target, allowedRoot) && fs.statSync(target).isDirectory()) return target;
|
|
692
|
+
} catch {}
|
|
693
|
+
return workspace;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeCandidatePath(value, root) {
|
|
697
|
+
const text = String(value || '').trim().replace(/^['"`]|['"`]$/g, '');
|
|
698
|
+
if (!text || text === '.' || text.includes('://') || /[*?\[\]{}]/.test(text)) return null;
|
|
699
|
+
const absolute = path.resolve(root, text);
|
|
700
|
+
if (!isInsidePath(absolute, root)) return null;
|
|
701
|
+
const relative = path.relative(root, absolute).replace(/\\/g, '/');
|
|
702
|
+
if (!relative || relative === '.') return null;
|
|
703
|
+
if (!relative.includes('/') && !path.extname(relative) && !fs.existsSync(absolute)) return null;
|
|
704
|
+
return relative;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function safeCandidateRef(value) {
|
|
708
|
+
const text = String(value || '').trim();
|
|
709
|
+
return safeGitRevToken(text) ? text : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function candidateGit(root, args) {
|
|
713
|
+
try {
|
|
714
|
+
return spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 10000 });
|
|
715
|
+
} catch {
|
|
716
|
+
return { status: 1, stdout: '', stderr: '' };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function mergeChangedLineMaps(target, source) {
|
|
721
|
+
for (const [file, lines] of source) {
|
|
722
|
+
const merged = target.get(file) || new Set();
|
|
723
|
+
for (const line of lines) merged.add(line);
|
|
724
|
+
target.set(file, merged);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function declaredCandidateChangedLines(root, declaredPaths) {
|
|
729
|
+
const repoCheck = candidateGit(root, ['rev-parse', '--is-inside-work-tree']);
|
|
730
|
+
if (repoCheck.status !== 0 || String(repoCheck.stdout).trim() !== 'true') return null;
|
|
731
|
+
const { gitChangedLines } = require('../commands/slop');
|
|
732
|
+
const changedPaths = new Set();
|
|
733
|
+
const changedLines = new Map();
|
|
734
|
+
const addDiff = (args, lines) => {
|
|
735
|
+
const names = candidateGit(root, args);
|
|
736
|
+
if (names.status !== 0) return;
|
|
737
|
+
String(names.stdout || '').split('\0').filter(Boolean)
|
|
738
|
+
.map((entry) => normalizeCandidatePath(entry, root))
|
|
739
|
+
.filter(Boolean)
|
|
740
|
+
.forEach((entry) => changedPaths.add(entry));
|
|
741
|
+
mergeChangedLineMaps(changedLines, lines);
|
|
742
|
+
};
|
|
743
|
+
addDiff(['diff', '--name-only', '-z'], gitChangedLines(false, root));
|
|
744
|
+
addDiff(['diff', '--cached', '--name-only', '-z'], gitChangedLines(true, root));
|
|
745
|
+
for (const base of ['origin/master', 'origin/main', 'master', 'main']) {
|
|
746
|
+
const baseResult = candidateGit(root, ['rev-parse', base]);
|
|
747
|
+
const headResult = candidateGit(root, ['rev-parse', 'HEAD']);
|
|
748
|
+
if (baseResult.status !== 0 || headResult.status !== 0) continue;
|
|
749
|
+
if (String(baseResult.stdout).trim() === String(headResult.stdout).trim()) break;
|
|
750
|
+
const range = `${base}...HEAD`;
|
|
751
|
+
addDiff(['diff', '--name-only', '-z', range], gitChangedLines(false, root, range));
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
const pathIsChanged = (declaredPath) => [...changedPaths]
|
|
755
|
+
.some((changedPath) => changedPath === declaredPath || changedPath.startsWith(`${declaredPath}/`));
|
|
756
|
+
return declaredPaths.every(pathIsChanged) ? changedLines : null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function candidateBranchScope(task, root) {
|
|
760
|
+
const metadata = task.metadata || {};
|
|
761
|
+
const branch = safeCandidateRef(
|
|
762
|
+
metadata.branch
|
|
763
|
+
|| metadata.worktree_branch
|
|
764
|
+
|| metadata.git_branch
|
|
765
|
+
|| metadata.pr_branch
|
|
766
|
+
|| metadata.head_branch
|
|
767
|
+
|| metadata.worktree?.branch,
|
|
768
|
+
);
|
|
769
|
+
if (!branch) return null;
|
|
770
|
+
const base = safeCandidateRef(
|
|
771
|
+
metadata.base
|
|
772
|
+
|| metadata.base_ref
|
|
773
|
+
|| metadata.target_ref
|
|
774
|
+
|| metadata.target_branch
|
|
775
|
+
|| metadata.worktree?.base,
|
|
776
|
+
);
|
|
777
|
+
if (!base) return { certain: false, source: 'branch', paths: [], detail: 'candidate branch has no exact base ref' };
|
|
778
|
+
const headResult = candidateGit(root, ['rev-parse', 'HEAD']);
|
|
779
|
+
const branchResult = candidateGit(root, ['rev-parse', branch]);
|
|
780
|
+
if (headResult.status !== 0 || branchResult.status !== 0) {
|
|
781
|
+
return { certain: false, source: 'branch', paths: [], detail: 'candidate branch ref is unavailable' };
|
|
782
|
+
}
|
|
783
|
+
if (String(headResult.stdout).trim() !== String(branchResult.stdout).trim()) {
|
|
784
|
+
return { certain: false, source: 'branch', paths: [], detail: 'candidate branch content is not checked out here' };
|
|
785
|
+
}
|
|
786
|
+
const names = candidateGit(root, ['diff', '--name-only', '-z', `${base}...${branch}`]);
|
|
787
|
+
if (names.status !== 0) {
|
|
788
|
+
return { certain: false, source: 'branch', paths: [], detail: 'candidate branch diff is unavailable' };
|
|
789
|
+
}
|
|
790
|
+
const paths = String(names.stdout || '').split('\0').filter(Boolean);
|
|
791
|
+
const normalized = paths.map((entry) => normalizeCandidatePath(entry, root));
|
|
792
|
+
if (normalized.some((entry) => !entry)) {
|
|
793
|
+
return { certain: false, source: 'branch', paths: [], detail: 'candidate branch diff contains an unsafe path' };
|
|
794
|
+
}
|
|
795
|
+
const { gitChangedLines } = require('../commands/slop');
|
|
796
|
+
return {
|
|
797
|
+
certain: true,
|
|
798
|
+
source: 'branch',
|
|
799
|
+
paths: [...new Set(normalized)],
|
|
800
|
+
changed_lines: gitChangedLines(false, root, `${base}...${branch}`),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function candidateTouchedScope(task) {
|
|
805
|
+
const root = candidateWorkspaceRoot(task);
|
|
806
|
+
const declared = declaredCandidatePathValues(task);
|
|
807
|
+
if (declared.length) {
|
|
808
|
+
const normalized = declared.map((entry) => normalizeCandidatePath(entry, root));
|
|
809
|
+
if (normalized.some((entry) => !entry)) {
|
|
810
|
+
return { certain: false, source: 'declared', root, paths: [], detail: 'declared candidate paths are ambiguous or outside the workspace' };
|
|
811
|
+
}
|
|
812
|
+
const paths = [...new Set(normalized)];
|
|
813
|
+
return {
|
|
814
|
+
certain: true,
|
|
815
|
+
source: 'declared',
|
|
816
|
+
root,
|
|
817
|
+
paths,
|
|
818
|
+
changed_lines: declaredCandidateChangedLines(root, paths),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const branch = candidateBranchScope(task, root);
|
|
822
|
+
if (branch) return { root, ...branch };
|
|
823
|
+
return { certain: false, source: 'unknown', root, paths: [], detail: 'no exact diff or declared artifact paths were recorded' };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function candidatePolicyGate(task, options = {}) {
|
|
827
|
+
const scope = candidateTouchedScope(task);
|
|
828
|
+
const gate = {
|
|
829
|
+
scope: scope.source,
|
|
830
|
+
paths: scope.paths,
|
|
831
|
+
advisory_only: !scope.certain,
|
|
832
|
+
advisories: [],
|
|
833
|
+
bypassed: false,
|
|
834
|
+
};
|
|
835
|
+
if (!scope.certain) {
|
|
836
|
+
gate.advisories.push({ reason: 'candidate_scope_unknown', detail: scope.detail });
|
|
837
|
+
return { ok: true, gate };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const { scanPaths } = require('../commands/slop');
|
|
841
|
+
const scanned = scanPaths(scope.paths, {
|
|
842
|
+
root: scope.root,
|
|
843
|
+
changedLines: scope.changed_lines,
|
|
844
|
+
});
|
|
845
|
+
if (scanned.findings.length) {
|
|
846
|
+
const findings = scanned.findings.map((finding) => ({
|
|
847
|
+
file: path.relative(scope.root, finding.file).replace(/\\/g, '/'),
|
|
848
|
+
line: finding.line,
|
|
849
|
+
rule: finding.rule,
|
|
850
|
+
severity: finding.sev,
|
|
851
|
+
}));
|
|
852
|
+
const rules = [...new Set(findings.map((finding) => finding.rule))];
|
|
853
|
+
const offenders = findings.map((finding) => `${finding.file}:${finding.line} ${finding.rule}`);
|
|
854
|
+
return {
|
|
855
|
+
ok: false,
|
|
856
|
+
eligible: false,
|
|
857
|
+
reason: 'slop_gate',
|
|
858
|
+
rules,
|
|
859
|
+
findings,
|
|
860
|
+
offenders,
|
|
861
|
+
gate,
|
|
862
|
+
message: 'the touched prose or user-facing text breaks a deterministic taste rule, so this work cannot land yet.',
|
|
863
|
+
next_action: `fix ${offenders.join(', ')}, then re-certify`,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const matchedLessons = policyLessonsForFiles(
|
|
868
|
+
scope.paths,
|
|
869
|
+
readPolicyLessons(scope.root),
|
|
870
|
+
scope.root,
|
|
871
|
+
);
|
|
872
|
+
const failing = [];
|
|
873
|
+
for (const lesson of matchedLessons) {
|
|
874
|
+
if (!lesson.detector) {
|
|
875
|
+
gate.advisories.push({ reason: 'lesson_has_no_detector', lesson_id: lesson.id, matched_path: lesson.matched_path });
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (options.executeDetectors === false) {
|
|
879
|
+
gate.advisories.push({ reason: 'lesson_detector_pending', lesson_id: lesson.id, matched_path: lesson.matched_path });
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const result = runVerifyCommandCached(lesson.detector, scope.root, options.verifyCache || null);
|
|
883
|
+
if (result.reason === 'verify_failed' && Number.isInteger(result.status) && result.status !== 0) {
|
|
884
|
+
failing.push({
|
|
885
|
+
lesson_id: lesson.id,
|
|
886
|
+
detector: lesson.detector,
|
|
887
|
+
exit_code: result.status,
|
|
888
|
+
matched_path: lesson.matched_path,
|
|
889
|
+
});
|
|
890
|
+
} else if (!result.ok) {
|
|
891
|
+
gate.advisories.push({
|
|
892
|
+
reason: 'lesson_detector_unrunnable',
|
|
893
|
+
lesson_id: lesson.id,
|
|
894
|
+
detector_reason: result.reason,
|
|
895
|
+
matched_path: lesson.matched_path,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (failing.length) {
|
|
900
|
+
const lessonIds = failing.map((entry) => entry.lesson_id);
|
|
901
|
+
return {
|
|
902
|
+
ok: false,
|
|
903
|
+
eligible: false,
|
|
904
|
+
reason: 'lesson_gate',
|
|
905
|
+
lesson_id: lessonIds[0],
|
|
906
|
+
lesson_ids: lessonIds,
|
|
907
|
+
lessons: failing,
|
|
908
|
+
gate,
|
|
909
|
+
message: `the touched work breaks detector-backed lesson ${lessonIds.join(', ')}, so it cannot land yet.`,
|
|
910
|
+
next_action: `repair lesson ${lessonIds.join(', ')} and rerun its detector, then re-certify`,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
return { ok: true, gate };
|
|
914
|
+
}
|
|
915
|
+
|
|
623
916
|
function strictVerifyMissingResult(ref) {
|
|
624
917
|
return {
|
|
625
918
|
eligible: false,
|
|
@@ -822,7 +1115,10 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
822
1115
|
if (verify && !executeVerify && !isAutoCertifyVerifyCommandAllowed(verify)) {
|
|
823
1116
|
return { eligible: false, ref, reason: 'verify_command_not_allowed', verify };
|
|
824
1117
|
}
|
|
1118
|
+
let candidateGate = null;
|
|
825
1119
|
if (executeVerify) {
|
|
1120
|
+
candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
|
|
1121
|
+
if (!candidateGate.ok) return { ...candidateGate, ref };
|
|
826
1122
|
const hygieneBlock = hygieneBlockResult(task, ref);
|
|
827
1123
|
if (hygieneBlock) return hygieneBlock;
|
|
828
1124
|
}
|
|
@@ -834,6 +1130,7 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
834
1130
|
proof,
|
|
835
1131
|
policy: 'all_but_protected',
|
|
836
1132
|
verification_pending: tierRequiresStrictVerify && Boolean(verify) && !executeVerify,
|
|
1133
|
+
...(candidateGate ? { candidate_gate: candidateGate.gate } : {}),
|
|
837
1134
|
};
|
|
838
1135
|
}
|
|
839
1136
|
|
|
@@ -900,7 +1197,10 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
900
1197
|
}
|
|
901
1198
|
}
|
|
902
1199
|
|
|
1200
|
+
let candidateGate = null;
|
|
903
1201
|
if (executeVerify) {
|
|
1202
|
+
candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
|
|
1203
|
+
if (!candidateGate.ok) return { ...candidateGate, ref };
|
|
904
1204
|
const hygieneBlock = hygieneBlockResult(task, ref);
|
|
905
1205
|
if (hygieneBlock) return hygieneBlock;
|
|
906
1206
|
}
|
|
@@ -914,12 +1214,14 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
914
1214
|
proof,
|
|
915
1215
|
policy: requiresStrictVerify ? 'strict_verify' : 'independent_reviewer',
|
|
916
1216
|
verification_pending: requiresStrictVerify && !executeVerify,
|
|
1217
|
+
...(candidateGate ? { candidate_gate: candidateGate.gate } : {}),
|
|
917
1218
|
};
|
|
918
1219
|
}
|
|
919
1220
|
|
|
920
1221
|
module.exports = {
|
|
921
1222
|
AGENT_CERTIFICATION_REVIEW_PASSES,
|
|
922
1223
|
DENIED_TAGS,
|
|
1224
|
+
candidatePolicyGate,
|
|
923
1225
|
declaredProtectedLane,
|
|
924
1226
|
sniffedProtectedLane,
|
|
925
1227
|
evaluateAutoAccept,
|
package/lib/cloud-mission.js
CHANGED
|
@@ -104,7 +104,10 @@ function requestError(result) {
|
|
|
104
104
|
const hint = /business/i.test(String(backendDetail || errorDetail || ''))
|
|
105
105
|
? '\ntry: --agent <id of a business-attached agent>'
|
|
106
106
|
: '';
|
|
107
|
-
|
|
107
|
+
const error = new CloudMissionError(`cloud mission request failed${status}${detail}${hint}`, 1);
|
|
108
|
+
error.status = result && result.status ? result.status : 0;
|
|
109
|
+
error.detail = errorDetail ? String(errorDetail) : '';
|
|
110
|
+
return error;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
// Every cloud mission carries the receipt voice contract: the wish text is the
|
|
@@ -118,7 +121,14 @@ function withReceiptVoice(text) {
|
|
|
118
121
|
return `${body}\n\n${RECEIPT_VOICE_CONTRACT}`;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
async function enqueueCloudMission({
|
|
124
|
+
async function enqueueCloudMission({
|
|
125
|
+
text,
|
|
126
|
+
lane = 'fast',
|
|
127
|
+
agentId,
|
|
128
|
+
businessId,
|
|
129
|
+
idempotencyKey,
|
|
130
|
+
budgetUsd,
|
|
131
|
+
}, deps = {}) {
|
|
122
132
|
if (!VALID_CLOUD_LANES.has(lane)) {
|
|
123
133
|
throw new CloudMissionError(`invalid --lane "${lane}". expected fast, pro, or max`, 2);
|
|
124
134
|
}
|
|
@@ -126,6 +136,9 @@ async function enqueueCloudMission({ text, lane = 'fast', agentId }, deps = {})
|
|
|
126
136
|
const request = deps.apiRequestJson || apiRequestJson;
|
|
127
137
|
const body = { text: withReceiptVoice(text), lane };
|
|
128
138
|
if (agentId) body.agent_id = agentId;
|
|
139
|
+
if (businessId) body.business_id = businessId;
|
|
140
|
+
if (idempotencyKey !== undefined) body.idempotency_key = idempotencyKey;
|
|
141
|
+
if (budgetUsd !== undefined) body.budget_usd = budgetUsd;
|
|
129
142
|
const response = await request('/atris2/missions', {
|
|
130
143
|
method: 'POST',
|
|
131
144
|
token,
|
|
@@ -152,6 +165,45 @@ async function fetchCloudMissionStatus(taskId, deps = {}) {
|
|
|
152
165
|
return response.data;
|
|
153
166
|
}
|
|
154
167
|
|
|
168
|
+
async function fetchCurrentCloudMission(deps = {}) {
|
|
169
|
+
const token = credentialsToken(deps.loadCredentials || loadCredentials);
|
|
170
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
171
|
+
// This route is owned by the mission-core backend. Keep the client call here
|
|
172
|
+
// while that backend change lands; the human command layer translates a 404.
|
|
173
|
+
const response = await request('/atris2/missions/current', {
|
|
174
|
+
method: 'GET',
|
|
175
|
+
token,
|
|
176
|
+
});
|
|
177
|
+
if (!response || !response.ok) throw requestError(response);
|
|
178
|
+
if (!response.data || !(response.data.mission_id || response.data.task_id || response.data.id || response.data.mission || response.data.card)) {
|
|
179
|
+
throw new CloudMissionError('current cloud mission response did not include a mission', 1);
|
|
180
|
+
}
|
|
181
|
+
return response.data;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function updateCloudMission(taskId, action, body = {}, deps = {}) {
|
|
185
|
+
const token = credentialsToken(deps.loadCredentials || loadCredentials);
|
|
186
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
187
|
+
const response = await request(`/mission-control/missions/${encodeURIComponent(taskId)}/${action}`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
token,
|
|
190
|
+
body,
|
|
191
|
+
});
|
|
192
|
+
if (!response || !response.ok) throw requestError(response);
|
|
193
|
+
return response.data || {};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function fetchCloudMissionChecks(taskId, deps = {}) {
|
|
197
|
+
const token = credentialsToken(deps.loadCredentials || loadCredentials);
|
|
198
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
199
|
+
const response = await request(`/mission-control/missions/${encodeURIComponent(taskId)}`, {
|
|
200
|
+
method: 'GET',
|
|
201
|
+
token,
|
|
202
|
+
});
|
|
203
|
+
if (!response || !response.ok) throw requestError(response);
|
|
204
|
+
return response.data || {};
|
|
205
|
+
}
|
|
206
|
+
|
|
155
207
|
function appendCloudMissionReceipt(root, receipt) {
|
|
156
208
|
const stateDir = path.join(root, '.atris', 'state');
|
|
157
209
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
@@ -248,6 +300,11 @@ module.exports = {
|
|
|
248
300
|
withReceiptVoice,
|
|
249
301
|
parseCloudRunArgs,
|
|
250
302
|
enqueueCloudMission,
|
|
303
|
+
fetchCloudMissionStatus,
|
|
304
|
+
fetchCurrentCloudMission,
|
|
305
|
+
updateCloudMission,
|
|
306
|
+
fetchCloudMissionChecks,
|
|
307
|
+
appendCloudMissionReceipt,
|
|
251
308
|
runCloudMissionCommand,
|
|
252
309
|
statusCloudMissionCommand,
|
|
253
310
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// this list; keeping it in one place is what stops an engine from being blocked
|
|
7
7
|
// by its own prompt file.
|
|
8
8
|
const CONDUCTOR_UNTRACKED_PATTERN =
|
|
9
|
-
/^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
|
|
9
|
+
/^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog-[^/]+\.json|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
|
|
10
10
|
|
|
11
11
|
function isConductorArtifact(file) {
|
|
12
12
|
return CONDUCTOR_UNTRACKED_PATTERN.test(String(file || ''));
|