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.
Files changed (45) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +37 -4
  9. package/commands/autoland.js +15 -1
  10. package/commands/caretaker.js +303 -0
  11. package/commands/clean.js +76 -0
  12. package/commands/engine-watch.js +212 -0
  13. package/commands/engine.js +99 -11
  14. package/commands/founder.js +304 -0
  15. package/commands/human-missions.js +844 -0
  16. package/commands/init.js +16 -7
  17. package/commands/lesson.js +178 -4
  18. package/commands/mission.js +124 -69
  19. package/commands/slop.js +34 -3
  20. package/commands/task.js +51 -4
  21. package/commands/team.js +329 -13
  22. package/commands/verify.js +99 -6
  23. package/commands/worktree.js +119 -4
  24. package/lib/auto-accept-certified.js +302 -0
  25. package/lib/cloud-mission.js +59 -2
  26. package/lib/conductor-artifacts.js +1 -1
  27. package/lib/dispatch-scout.js +383 -0
  28. package/lib/engine-ask.js +645 -0
  29. package/lib/engine-job-lifecycle.js +65 -0
  30. package/lib/engine-receipt-sweep.js +98 -0
  31. package/lib/engine-registry.js +2 -2
  32. package/lib/engine-validate.js +374 -0
  33. package/lib/fleet.js +459 -106
  34. package/lib/known-commands.js +2 -2
  35. package/lib/lesson-ledger.js +84 -0
  36. package/lib/member-alive.js +2 -2
  37. package/lib/policy-lessons.js +70 -0
  38. package/lib/receipt-evidence.js +56 -1
  39. package/lib/runner-command.js +1 -1
  40. package/lib/secret-gateway.js +588 -0
  41. package/lib/team-presence.js +13 -1
  42. package/lib/voice-gate.js +6 -0
  43. package/lib/wish-audit.js +5 -205
  44. package/lib/wish-delegate.js +5 -2
  45. package/package.json +6 -1
@@ -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,
@@ -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
- return new CloudMissionError(`cloud mission request failed${status}${detail}${hint}`, 1);
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({ text, lane = 'fast', agentId }, deps = {}) {
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 || ''));