atris 3.63.0 → 3.65.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 (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -5443,7 +5443,12 @@ function isWikiMinerNoiseName(value) {
5443
5443
  const name = compactSentence(value || '', 120);
5444
5444
  if (!name) return true;
5445
5445
  if (/^[A-Z]{2,}$/.test(name)) return false;
5446
- return /^(?:i|me|my|mine|myself|we|us|our|ours|ourselves|you|your|yours|yourself|yourselves|he|him|his|himself|she|her|hers|herself|it|its|itself|they|them|their|theirs|themselves|this|that|these|those|the|wiki|mission)$/i.test(name);
5446
+ return /^(?:i|me|my|mine|myself|we|us|our|ours|ourselves|you|your|yours|yourself|yourselves|he|him|his|himself|she|her|hers|herself|it|its|itself|they|them|their|theirs|themselves|this|that|these|those|here|there|where|when|what|who|whom|which|why|how|everything|nothing|something|anything|everyone|anyone|someone|nobody|everybody|anybody|somebody|none|the|wiki|mission)$/i.test(name);
5447
+ }
5448
+
5449
+ function isWikiMinerSelfLoop(relationship) {
5450
+ const from = lowerCompact(relationship?.from);
5451
+ return from !== '' && from === lowerCompact(relationship?.to);
5447
5452
  }
5448
5453
 
5449
5454
  function normalizeWikiMinerExtraction(raw) {
@@ -5462,7 +5467,7 @@ function normalizeWikiMinerExtraction(raw) {
5462
5467
  to: compactSentence(relationship?.to || '', 120),
5463
5468
  type: allowedRelationTypes.has(String(relationship?.type || '').toLowerCase()) ? String(relationship.type).toLowerCase() : 'uses',
5464
5469
  }))
5465
- .filter((relationship) => !isWikiMinerNoiseName(relationship.from) && !isWikiMinerNoiseName(relationship.to));
5470
+ .filter((relationship) => !isWikiMinerNoiseName(relationship.from) && !isWikiMinerNoiseName(relationship.to) && !isWikiMinerSelfLoop(relationship));
5466
5471
  return { entities, relationships };
5467
5472
  }
5468
5473
 
@@ -5484,7 +5489,9 @@ function heuristicWikiMinerExtraction(pagePath, pageContent) {
5484
5489
  }
5485
5490
  const entities = [{ type: 'concept', name: title }];
5486
5491
  for (const name of [...candidates].slice(0, 12)) entities.push({ type: /agent|api|sdk|cli|hub|system/i.test(name) ? 'system' : 'concept', name });
5487
- const relationships = [...candidates].slice(0, 12).map((name) => ({ from: title, to: name, type: 'uses' }));
5492
+ const relationships = [...candidates].slice(0, 12)
5493
+ .map((name) => ({ from: title, to: name, type: 'uses' }))
5494
+ .filter((relationship) => !isWikiMinerSelfLoop(relationship));
5488
5495
  return { entities, relationships };
5489
5496
  }
5490
5497
 
@@ -5538,11 +5545,12 @@ function mergeWikiGraph(graph, extraction, sourcePath) {
5538
5545
 
5539
5546
  const relationshipMap = new Map();
5540
5547
  for (const relationship of graph.relationships || []) {
5541
- if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to)) continue;
5548
+ if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to) || isWikiMinerSelfLoop(relationship)) continue;
5542
5549
  const key = `${lowerCompact(relationship.from)}|${String(relationship.type || 'uses').toLowerCase()}|${lowerCompact(relationship.to)}`;
5543
5550
  relationshipMap.set(key, { ...relationship, sources: Array.isArray(relationship.sources) ? relationship.sources : [] });
5544
5551
  }
5545
5552
  for (const relationship of extraction.relationships || []) {
5553
+ if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to) || isWikiMinerSelfLoop(relationship)) continue;
5546
5554
  const key = `${lowerCompact(relationship.from)}|${relationship.type}|${lowerCompact(relationship.to)}`;
5547
5555
  const existing = relationshipMap.get(key) || { from: relationship.from, to: relationship.to, type: relationship.type, sources: [] };
5548
5556
  if (sourcePath && !existing.sources.includes(sourcePath)) existing.sources.push(sourcePath);
@@ -5819,7 +5827,6 @@ async function callSupervisorLlm(receipts, logs) {
5819
5827
  const output = { isTTY: false, write() { return true; } };
5820
5828
  const result = await postTurn(supervisorPrompt(receipts, logs), {
5821
5829
  mode: process.env.ATRIS_SUPERVISOR_LLM_MODE || 'fast',
5822
- route: 'local',
5823
5830
  cwd: process.cwd(),
5824
5831
  output,
5825
5832
  color: false,
@@ -5905,11 +5912,21 @@ async function runSupervisorWake(name, paths, { execute = false } = {}) {
5905
5912
  analysis = normalizeSupervisorAnalysis(llm.analysis, { status: 'ok', llm_source: llm.source });
5906
5913
  if (!analysis) {
5907
5914
  reason = 'llm_json_parse_failed';
5908
- analysis = emptySupervisorRecommendations({ status: 'parse_error', llm_source: llm.source, llm_error: 'invalid_analysis_shape' });
5915
+ analysis = {
5916
+ ...fallbackSupervisorAnalysis(receipts),
5917
+ status: 'parse_error',
5918
+ llm_source: llm.source,
5919
+ llm_error: 'invalid_analysis_shape',
5920
+ };
5909
5921
  }
5910
5922
  } else if (llm?.error) {
5911
5923
  reason = llm.error === 'invalid_json' ? 'llm_json_parse_failed' : 'llm_analysis_failed';
5912
- analysis = emptySupervisorRecommendations({ status: llm.error === 'invalid_json' ? 'parse_error' : 'llm_error', llm_source: llm.source, llm_error: llm.error });
5924
+ analysis = {
5925
+ ...fallbackSupervisorAnalysis(receipts),
5926
+ status: llm.error === 'invalid_json' ? 'parse_error' : 'llm_error',
5927
+ llm_source: llm.source,
5928
+ llm_error: llm.error,
5929
+ };
5913
5930
  } else {
5914
5931
  reason = 'llm_not_configured';
5915
5932
  analysis = fallbackSupervisorAnalysis(receipts);
@@ -7351,7 +7368,7 @@ async function runGeneralistWake(name, paths, { execute = false, domainInput = {
7351
7368
  record: domain.ok && proof.status === 'ok',
7352
7369
  libraryBefore: crossDomainLibraryBefore,
7353
7370
  receiptPath,
7354
- proofPath,
7371
+ proofPath: execute ? proofPath : null,
7355
7372
  });
7356
7373
  proof.learning = {
7357
7374
  library_path: path.relative(root, learning.library_path),
@@ -7782,6 +7799,24 @@ function wakeRuleDirtyMemberScope(ctx) {
7782
7799
  };
7783
7800
  }
7784
7801
 
7802
+ // Rule: a goal pinned to a terminal mission must not mint another doomed tick.
7803
+ // The goal stays active until a human retires it or revives the mission, so the
7804
+ // wake surfaces that decision instead of proposing `member tick --goal` on a
7805
+ // dead spine. Unknown mission ids pass through: they may live in a worktree.
7806
+ function wakeRuleTerminalMissionGoal(ctx) {
7807
+ const mission = ctx.goalTerminalMission;
7808
+ if (!ctx.goal || !mission) return null;
7809
+ return {
7810
+ decision: 'ask',
7811
+ reason: `goal_mission_terminal:${mission.status}`,
7812
+ needs_user: true,
7813
+ ask: `Goal "${ctx.goal.title}" is pinned to mission ${mission.id} (${mission.status}). Retire the goal or revive the mission, then wake again.`,
7814
+ next_command: `atris member goal-from-mission ${ctx.name}`,
7815
+ goal: ctx.goal,
7816
+ current_experiment: ctx.current || null,
7817
+ };
7818
+ }
7819
+
7785
7820
  // Rule: nothing blocks the member, so take the safe next bounded step.
7786
7821
  function wakeRuleSafeBoundedTick(ctx) {
7787
7822
  return {
@@ -7802,6 +7837,7 @@ const WAKE_DECISION_RULES = [
7802
7837
  wakeRuleOpenReviewableProposal,
7803
7838
  wakeRuleScoredNonTickCandidate,
7804
7839
  wakeRuleDirtyMemberScope,
7840
+ wakeRuleTerminalMissionGoal,
7805
7841
  wakeRuleSafeBoundedTick,
7806
7842
  ];
7807
7843
 
@@ -7815,6 +7851,8 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7815
7851
  const steering = readSteeringMemory(paths, name);
7816
7852
  const state = loadMemberGoals(name, paths);
7817
7853
  const goal = activeGoal(state);
7854
+ const pinnedMission = goal?.mission_id ? memberRunMissionMap().get(String(goal.mission_id).trim()) || null : null;
7855
+ const goalTerminalMission = pinnedMission && MISSION_TERMINAL_STATUSES.has(lowerCompact(pinnedMission.status || '')) ? pinnedMission : null;
7818
7856
  const current = memberOpenExperiment(state);
7819
7857
  const generalistDecision = wakeRuleGeneralistDomainFile(name, { purpose, steering, state, goal, current });
7820
7858
  if (generalistDecision) return generalistDecision;
@@ -7860,7 +7898,7 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7860
7898
  next_command: wakeScores.selected.next_command,
7861
7899
  } : null;
7862
7900
 
7863
- const ctx = { name, force, purpose, nowFile, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
7901
+ const ctx = { name, force, purpose, nowFile, steering, state, goal, goalTerminalMission, current, blocked, evidence, workspace, checks, wakeScores };
7864
7902
  for (const rule of WAKE_DECISION_RULES) {
7865
7903
  const verdict = rule(ctx);
7866
7904
  if (verdict) return composeWakeDecision(ctx, verdict);
@@ -7,6 +7,7 @@ const readline = require('readline');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
9
9
  const { memberProcessPrompt } = require('../lib/member-context');
10
+ const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
10
11
  const {
11
12
  compactErrorPayload,
12
13
  compactSuccessPayload,
@@ -570,7 +571,7 @@ function applyMissionRunnerProfile(runner) {
570
571
  // it only ever takes an explicit pin (the mission's model or the roster
571
572
  // line's); '' lets the engine ride its own default.
572
573
  // A mission often still carries the claude model it was created with; an
573
- // engine that runs only its own models (codex) drops it.
574
+ // engine that runs only its own models (codex, grok) drops it.
574
575
  const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
575
576
 
576
577
  function resolveMissionTickRunnerModel(mission) {
@@ -8634,7 +8635,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8634
8635
  `# Mission Tick ${tickIndex}/${maxTicks}`,
8635
8636
  ...pingLines,
8636
8637
  ``,
8637
- `**Objective:** ${mission.objective}`,
8638
+ missionObjectiveLine(mission.objective),
8638
8639
  `**Owner:** ${mission.owner}`,
8639
8640
  `**Lane:** ${frozen.lane}`,
8640
8641
  `**Cadence:** ${mission.cadence}`,
@@ -8648,7 +8649,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8648
8649
  ``,
8649
8650
  `## Your task`,
8650
8651
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
8651
- `- You are the member "${mission.owner}". Read atris/team/${mission.owner}/MEMBER.md (and SOUL.md if present) before acting, work in that identity, inside its scope and stop rules. After your work, append what you did and what you learned to atris/team/${mission.owner}/logs/<today's date>.md.`,
8652
+ `- ${memberIdentityLine(mission.owner)} Read atris/team/${mission.owner}/MEMBER.md (and SOUL.md if present) before acting, work in that identity, inside its scope and stop rules. After your work, append what you did and what you learned to atris/team/${mission.owner}/logs/<today's date>.md.`,
8652
8653
  `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
8653
8654
  `- Pick the smallest concrete action that moves the mission forward.`,
8654
8655
  `- Before acting, state your single next move in one sentence.`,
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const os = require('os');
5
4
  const path = require('path');
5
+ const { stateHome } = require('../lib/state-home');
6
6
 
7
7
  function slugify(value, fallback = 'atris-pack') {
8
8
  return String(value || fallback)
@@ -69,7 +69,7 @@ function isNonEmptyTarget(targetDir) {
69
69
  function defaultAuthor() {
70
70
  try {
71
71
  const creds = JSON.parse(
72
- fs.readFileSync(path.join(os.homedir(), '.atris', 'credentials.json'), 'utf8')
72
+ fs.readFileSync(path.join(stateHome(), '.atris', 'credentials.json'), 'utf8')
73
73
  );
74
74
  if (creds && typeof creds.email === 'string' && creds.email.trim()) return creds.email.trim();
75
75
  } catch {
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { stateHome } = require('../lib/state-home');
4
4
  const { loadCredentials } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
 
@@ -104,7 +104,7 @@ async function publishAtris() {
104
104
  }
105
105
 
106
106
  // Save local copy
107
- const localDir = path.join(os.homedir(), '.atris', 'templates', name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
107
+ const localDir = path.join(stateHome(), '.atris', 'templates', name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
108
108
  fs.mkdirSync(localDir, { recursive: true });
109
109
 
110
110
  for (const f of files) {
package/commands/radar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('../lib/state-home');
6
7
  const { execFileSync } = require('child_process');
7
8
  const { parseScopeFlag, pathUnderRoot } = require('../lib/cli-scope');
8
9
 
@@ -346,7 +347,7 @@ function loadBusinessCollaboration(root, deps, team = {}, scorecardCount = count
346
347
  const business = readJsonFile(path.join(root, '.atris', 'business.json'), deps, null);
347
348
  const runtime = readJsonFile(path.join(root, '.atris', 'state', 'runtime.json'), deps, null);
348
349
  const sync = readJsonFile(path.join(root, '.atris', 'state', '_sync.json'), deps, null);
349
- const cache = readJsonFile(path.join(deps.homeDir || os.homedir(), '.atris', 'businesses.json'), deps, {}) || {};
350
+ const cache = readJsonFile(path.join(deps.homeDir || stateHome(), '.atris', 'businesses.json'), deps, {}) || {};
350
351
  const slug = business?.slug || sync?.workspace_slug || null;
351
352
  const cacheEntry = slug && cache ? cache[slug] : null;
352
353
  const hasAtris = deps.exists(path.join(root, 'atris'));
@@ -804,7 +805,7 @@ function collectRadar(options = {}) {
804
805
  readlink: options.readlinkSync || fs.readlinkSync,
805
806
  readdir: options.readdirSync || fs.readdirSync,
806
807
  platform: options.platform || os.platform(),
807
- homeDir: options.homeDir || os.homedir(),
808
+ homeDir: options.homeDir || stateHome(),
808
809
  };
809
810
  const nowMs = options.nowMs || Date.now();
810
811
  const tasks = loadTasks(root, deps);
@@ -2,8 +2,8 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
- const os = require('node:os');
6
5
  const path = require('node:path');
6
+ const { stateHome } = require('../lib/state-home');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { loadOverrides, pickLane } = require('../lib/ax-auto-lane');
9
9
 
@@ -28,7 +28,7 @@ function readFirstNamedFlag(args, names) {
28
28
  }
29
29
 
30
30
  function resolveRouterPaths(args = [], env = process.env) {
31
- const home = env.HOME || os.homedir();
31
+ const home = stateHome(env);
32
32
  return {
33
33
  picks: path.resolve(readFirstNamedFlag(args, ['--picks', '--picks-path'])
34
34
  || env.ATRIS_ROUTER_PICKS_PATH
@@ -589,12 +589,33 @@ function worktreeTask(root, deps) {
589
589
  return worktreeConfig(root, deps).task;
590
590
  }
591
591
 
592
+ // The branches a side copy starts from. A commit reachable from any of them
593
+ // was inherited, not made in the copy.
594
+ const WORKTREE_BASE_REFS = Object.freeze([
595
+ 'refs/remotes/origin/HEAD',
596
+ 'refs/remotes/origin/main',
597
+ 'refs/remotes/origin/master',
598
+ 'refs/heads/main',
599
+ 'refs/heads/master',
600
+ ]);
601
+
602
+ function worktreeBaseRefs(root, deps) {
603
+ const result = runGit(root, ['for-each-ref', '--format=%(refname)', ...WORKTREE_BASE_REFS], deps);
604
+ if (result.status !== 0) return [];
605
+ return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter((ref) => WORKTREE_BASE_REFS.includes(ref));
606
+ }
607
+
608
+ // Only a commit the copy added on top of its base counts. A fresh copy cut
609
+ // from someone else's commit has none, so its owner shows no work. A repo
610
+ // with no main, master, or origin default keeps the newest commit.
592
611
  function collectWorktreeActivityEvents(root, deps, events) {
593
612
  const roots = collectWorkspaceRoots(root, deps);
594
613
  for (const item of roots) {
595
614
  const owner = worktreeOwner(item.root, deps);
596
615
  if (!owner) continue;
597
- const log = runGit(item.root, ['log', '-1', '--format=%cI%x09%an%x09%s'], deps);
616
+ const bases = worktreeBaseRefs(item.root, deps);
617
+ const range = bases.length ? ['HEAD', '--not', ...bases] : [];
618
+ const log = runGit(item.root, ['log', '-1', '--format=%cI%x09%an%x09%s', ...range], deps);
598
619
  if (log.status !== 0 || !String(log.stdout || '').trim()) continue;
599
620
  const [ts, author, subject] = String(log.stdout || '').trim().split('\t');
600
621
  pushEvent(events, {
package/commands/task.js CHANGED
@@ -253,6 +253,8 @@ atris task - durable local task state (SQLite, gitignored)
253
253
  Sweep off-roadmap/duplicate work as archived (not failed);
254
254
  --from-failed opts in to relabel a fail-closed row (never done)
255
255
  atris task clear-done [--before <days>] [--dry-run] [--json] Archive completed rows, oldest first
256
+ atris task prune-test-junk [--yes] [--vacuum] [--json]
257
+ Count rows test runs left from temp folders; --yes backs up the db, then removes them
256
258
  atris task keep [--json] Put away finished and untouched work, then refresh the list
257
259
  atris task reap-mission-blockers [--json] Close blocker rows whose missions are complete or stopped
258
260
  atris task relabel-archived [--dry-run|--apply]
@@ -1421,7 +1423,7 @@ function taskReviewSummary(task) {
1421
1423
  const reviewPassCount = Number(metadata.agent_review_pass_count || payload.review_pass_count || 0);
1422
1424
  const agentCertified = metadata.agent_certified === true
1423
1425
  || payload.agent_certified === true
1424
- || reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES;
1426
+ || (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && reviewIntegrity.hasIndependentReview(task));
1425
1427
  const reviewedEventHas = (key) => reviewed && reviewed.event_type === 'reviewed'
1426
1428
  && Object.prototype.hasOwnProperty.call(payload, key);
1427
1429
  const clearedReviewFields = new Set(Array.isArray(payload.cleared_review_fields) ? payload.cleared_review_fields : []);
@@ -8841,7 +8843,7 @@ function taskAllowsReviewChat(task, { allowCertified = false } = {}) {
8841
8843
  if (allowCertified) return true;
8842
8844
  if (review.agent_certified === true || metadata.agent_certified === true) return false;
8843
8845
  const reviewPassCount = Number(review.agent_review_pass_count || metadata.agent_review_pass_count || 0);
8844
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES) return false;
8846
+ if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && reviewIntegrity.hasIndependentReview(task)) return false;
8845
8847
  const handoff = reviewHandoffForTask(task, { suppressExistingFollowUp: true });
8846
8848
  return !(handoff && (handoff.next_action === 'continue_work' || handoff.next_action === 'human_accept_waiting'));
8847
8849
  }
@@ -9814,6 +9816,78 @@ function cmdClearDone(args) {
9814
9816
  console.log(`cleared ${candidates.length} completed task(s).`);
9815
9817
  }
9816
9818
 
9819
+ function formatBytes(bytes) {
9820
+ const mb = Number(bytes || 0) / (1024 * 1024);
9821
+ return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(Number(bytes || 0) / 1024)} KB`;
9822
+ }
9823
+
9824
+ // Test runs that forgot ATRIS_TASKS_DB left rows in the shared database
9825
+ // under temp folders. Default is a read-only count; --yes backs up, then
9826
+ // removes those rows and their history. Real project rows are never touched.
9827
+ function cmdPruneTestJunk(args) {
9828
+ const taskDb = getTaskDb();
9829
+ const prune = require('../lib/task-prune');
9830
+ const dbPath = taskDb.getDbPath();
9831
+ const apply = hasFlag(args, '--yes') && !hasFlag(args, '--dry-run');
9832
+ const vacuum = apply && hasFlag(args, '--vacuum');
9833
+ if (!apply) {
9834
+ const plan = prune.dryRun(dbPath);
9835
+ const { rows, ...summary } = plan;
9836
+ if (wantsJson(args)) {
9837
+ printJson({ ok: true, action: 'prune-test-junk', dry_run: true, ...summary });
9838
+ return;
9839
+ }
9840
+ if (plan.missing) {
9841
+ console.log(`no task database at ${dbPath}. nothing to prune.`);
9842
+ return;
9843
+ }
9844
+ const r = plan.remove;
9845
+ console.log('prune-test-junk dry run: nothing was changed.');
9846
+ console.log(`database: ${dbPath} (${formatBytes(plan.db_bytes)}, ${plan.total_tasks} tasks)`);
9847
+ console.log(`would remove ${r.tasks} test task(s) from temp folders, ${r.history_rows} history row(s), `
9848
+ + `${r.stray_history_rows} stray history row(s), ${r.part_uses} part use row(s).`);
9849
+ for (const g of plan.groups) {
9850
+ console.log(` ${g.prefix.padEnd(22)} ${String(g.tasks).padStart(7)} tasks ${String(g.history_rows + g.stray_history_rows).padStart(8)} history rows`);
9851
+ }
9852
+ const k = plan.kept;
9853
+ console.log(`kept: ${k.folder_exists} temp task(s) whose folder still exists, ${k.recent} active in the last ${plan.grace_hours} hour(s), `
9854
+ + `${k.held} with history from a real project, ${k.stray_held + k.stray_recent} leftover history set(s) that are recent or name a real project.`);
9855
+ if (r.tasks || r.stray_history_rows) {
9856
+ console.log('run with --yes to remove them. a backup copy is written next to the database first.');
9857
+ }
9858
+ return;
9859
+ }
9860
+
9861
+ if (vacuum && !wantsJson(args)) {
9862
+ console.log('note: --vacuum rewrites the whole file and needs every other atris process idle; it waits a few seconds, then gives up.');
9863
+ }
9864
+ let result;
9865
+ try {
9866
+ result = prune.applyPrune(dbPath, { vacuum });
9867
+ } catch (err) {
9868
+ failTask('atris task prune-test-junk', 'prune_failed', `prune-test-junk stopped: ${err && err.message || err}`, 1);
9869
+ }
9870
+ if (wantsJson(args)) {
9871
+ printJson({ ok: true, action: 'prune-test-junk', dry_run: false, ...result });
9872
+ return;
9873
+ }
9874
+ if (result.missing) {
9875
+ console.log(`no task database at ${dbPath}. nothing to prune.`);
9876
+ return;
9877
+ }
9878
+ const r = result.removed;
9879
+ console.log(`backup: ${result.backup_path} (${result.backup_tasks} tasks)`);
9880
+ console.log(`removed ${r.tasks} test task(s), ${r.history_rows} history row(s), `
9881
+ + `${r.stray_history_rows} stray history row(s), ${r.part_uses} part use row(s).`);
9882
+ const sk = result.skipped;
9883
+ if (sk.held || sk.changed_since_backup || sk.recent || sk.folder_exists) {
9884
+ console.log(`left ${sk.changed_since_backup} that changed after the backup, ${sk.recent} that became active, `
9885
+ + `${sk.folder_exists} whose folder came back, and ${sk.held} with real-project history.`);
9886
+ }
9887
+ console.log(`database now has ${result.total_tasks} tasks, ${formatBytes(result.bytes_after)} on disk`
9888
+ + (result.vacuumed ? ' after vacuum.' : '. run again with --yes --vacuum to shrink the file.'));
9889
+ }
9890
+
9817
9891
  function cmdReapMissionBlockers(args) {
9818
9892
  const taskDb = getTaskDb();
9819
9893
  const db = taskDb.open();
@@ -10468,6 +10542,13 @@ function stampReadyVerifyMetadata(taskDb, db, taskId, verify) {
10468
10542
  if (!row) return;
10469
10543
  const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
10470
10544
  metadata.verify = verify;
10545
+ // A real verify stamped at ready means the creation-time degraded flag is
10546
+ // stale: clear it so `task show` stops warning about a missing check. A
10547
+ // diff-only verify keeps the flag; it still cannot fail on real breakage.
10548
+ if (verify.toLowerCase() !== 'git diff --check') {
10549
+ delete metadata.verification_status;
10550
+ delete metadata.verification_degraded_reason;
10551
+ }
10471
10552
  db.prepare(`
10472
10553
  UPDATE tasks
10473
10554
  SET metadata = ?,
@@ -13374,6 +13455,7 @@ async function runTaskCommand(args) {
13374
13455
  case 'fail': return cmdDone([...rest, '--failed']);
13375
13456
  case 'archive': return cmdArchive(rest);
13376
13457
  case 'clear-done': return cmdClearDone(rest);
13458
+ case 'prune-test-junk': return cmdPruneTestJunk(rest);
13377
13459
  case 'keep': return cmdKeep(rest);
13378
13460
  case 'reap-mission-blockers':
13379
13461
  case 'reap-blockers':