atris 3.31.0 → 3.32.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.
@@ -0,0 +1,144 @@
1
+ // atris run: thin front door over the mission runtime loop.
2
+ // One bounded pursuit: start a mission from the objective (or resume the most
3
+ // logical runnable mission), tick it, complete on pass, then exit.
4
+ // The old plan→do→review loop lives on behind `atris run --legacy`.
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const CLI_PATH = path.join(__dirname, '..', 'bin', 'atris.js');
9
+
10
+ const RUN_VALUE_FLAGS = new Set([
11
+ '--owner',
12
+ '--cadence',
13
+ '--minutes',
14
+ '--hours',
15
+ '--max-ticks',
16
+ '--max-wall',
17
+ '--runner',
18
+ '--runner-bin',
19
+ '--runner-template',
20
+ '--runner-model',
21
+ '--runner-profile',
22
+ ]);
23
+
24
+ function readValueFlag(args, name) {
25
+ const index = args.indexOf(name);
26
+ return index >= 0 && index + 1 < args.length ? args[index + 1] : null;
27
+ }
28
+
29
+ function positiveNumber(value) {
30
+ const parsed = Number(value);
31
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
32
+ }
33
+
34
+ function runBudgetSeconds(args = []) {
35
+ const hours = positiveNumber(readValueFlag(args, '--hours'));
36
+ if (hours) return Math.round(hours * 3600);
37
+ const minutes = positiveNumber(readValueFlag(args, '--minutes'));
38
+ if (minutes) return Math.round(minutes * 60);
39
+ return null;
40
+ }
41
+
42
+ function runObjective(args = []) {
43
+ const words = [];
44
+ for (let i = 0; i < args.length; i++) {
45
+ const arg = args[i];
46
+ if (RUN_VALUE_FLAGS.has(arg)) { i++; continue; }
47
+ if (arg.startsWith('-')) continue;
48
+ words.push(arg);
49
+ }
50
+ return words.join(' ').trim();
51
+ }
52
+
53
+ // Runners a spawned `mission run` can drive unattended.
54
+ const HEADLESS_DRIVABLE_RUNNERS = new Set(['claude', 'atris2']);
55
+ const CALLER_SESSION_RUNNERS = new Set(['codex_goal', 'caller_session', 'current_agent']);
56
+
57
+ function liveCodexSession(env = process.env) {
58
+ return Boolean(String(env.CODEX_THREAD_ID || '').trim());
59
+ }
60
+
61
+ function runnerDrivableForFrontDoor(runner, options = {}) {
62
+ const normalized = String(runner || '').trim().toLowerCase();
63
+ if (HEADLESS_DRIVABLE_RUNNERS.has(normalized)) return true;
64
+ if (options.allowCallerSessionRunners && CALLER_SESSION_RUNNERS.has(normalized)) return true;
65
+ return false;
66
+ }
67
+
68
+ // Most logical mission: a moving one beats a planned one, newer beats older.
69
+ // ready is excluded — it waits for review, not for another worker pass.
70
+ function pickRunnableMission(root = process.cwd(), missionMap = null, options = {}) {
71
+ let map = missionMap;
72
+ if (!map) {
73
+ try { map = require('./mission').loadMissionMap(root); } catch { return null; }
74
+ }
75
+ const candidates = Array.from(map.values())
76
+ .filter((mission) => mission && (mission.status === 'running' || mission.status === 'planning'))
77
+ .filter((mission) => runnerDrivableForFrontDoor(mission?.runner, options))
78
+ .filter((mission) => !/^decide and start the next useful mission after:/i.test(String(mission?.objective || '')))
79
+ .sort((a, b) => {
80
+ if (a.status !== b.status) return a.status === 'running' ? -1 : 1;
81
+ return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || ''));
82
+ });
83
+ return candidates[0] || null;
84
+ }
85
+
86
+ function runTickBudget(args, budgetSeconds) {
87
+ const explicit = positiveNumber(readValueFlag(args, '--max-ticks'));
88
+ if (explicit) return Math.floor(explicit);
89
+ // A timed run is a loop contract: keep picking the next useful move until
90
+ // the wall clock spends the budget (same rule as member run).
91
+ return budgetSeconds ? Math.max(4, Math.ceil(budgetSeconds / 300)) : 4;
92
+ }
93
+
94
+ async function runMissionFront(args = []) {
95
+ const objective = runObjective(args);
96
+ const budgetSeconds = runBudgetSeconds(args);
97
+ const maxTicks = runTickBudget(args, budgetSeconds);
98
+ const maxWallSeconds = positiveNumber(readValueFlag(args, '--max-wall')) || budgetSeconds || 1200;
99
+
100
+ if (objective) {
101
+ const { runRuntimeMissionLoop } = require('../lib/mission-runtime-loop');
102
+ const result = await runRuntimeMissionLoop({
103
+ objective,
104
+ owner: readValueFlag(args, '--owner') || 'mission-lead',
105
+ cadence: readValueFlag(args, '--cadence') || '',
106
+ maxTicks,
107
+ maxWallSeconds,
108
+ noComplete: args.includes('--no-complete'),
109
+ }, {
110
+ cliPath: CLI_PATH,
111
+ onProgress: (line) => console.log(` ${line}`),
112
+ });
113
+ console.log(result.text || '');
114
+ return result.ok ? 0 : 1;
115
+ }
116
+
117
+ const mission = pickRunnableMission(process.cwd(), null, {
118
+ allowCallerSessionRunners: liveCodexSession(),
119
+ });
120
+ if (!mission) {
121
+ console.log('No objective given and no runnable mission found.');
122
+ console.log('Start one: atris run "<objective>" [--minutes N] [--owner <member>]');
123
+ console.log('Or keep going indefinitely: atris autopilot');
124
+ return 1;
125
+ }
126
+ console.log(`Resuming mission ${mission.id} (${mission.status}): ${mission.objective}`);
127
+ const result = spawnSync(process.execPath, [
128
+ CLI_PATH, 'mission', 'run', mission.id,
129
+ '--max-ticks', String(maxTicks),
130
+ '--max-wall', String(maxWallSeconds),
131
+ '--complete-on-pass',
132
+ ], { cwd: process.cwd(), stdio: 'inherit' });
133
+ return result.status || 0;
134
+ }
135
+
136
+ module.exports = {
137
+ runMissionFront,
138
+ pickRunnableMission,
139
+ liveCodexSession,
140
+ runnerDrivableForFrontDoor,
141
+ runObjective,
142
+ runBudgetSeconds,
143
+ runTickBudget,
144
+ };
package/commands/run.js CHANGED
@@ -381,12 +381,15 @@ async function runAtris(options = {}) {
381
381
  process.exit(1);
382
382
  }
383
383
 
384
- // Check configured runner CLI is available.
385
- try {
386
- execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' });
387
- } catch (err) {
388
- console.error(runnerAvailabilityFailureMessage(err));
389
- process.exit(1);
384
+ // Check configured runner CLI is available. Dry runs preview context only —
385
+ // they must work on machines (and CI) without the runner installed.
386
+ if (!dryRun) {
387
+ try {
388
+ execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' });
389
+ } catch (err) {
390
+ console.error(runnerAvailabilityFailureMessage(err));
391
+ process.exit(1);
392
+ }
390
393
  }
391
394
 
392
395
  console.log('');
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ // atris sign: co-author trailer on every commit in an atris workspace.
5
+ // Installs a prepare-commit-msg hook that appends "Co-authored-by: Atris"
6
+ // when the repo has an atris/ folder — same credit line Claude and Cursor use.
7
+ // Idempotent and non-destructive: appends a marked block, removes only its own block.
8
+
9
+ const MARKER = '# atris co-author';
10
+ const TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
11
+
12
+ const HOOK_BLOCK = `
13
+ ${MARKER}
14
+ if [ -d "$(git rev-parse --show-toplevel 2>/dev/null)/atris" ] && [ "$2" != "merge" ] && [ "$2" != "squash" ]; then
15
+ if ! grep -qi "^co-authored-by: atris" "$1"; then
16
+ printf '\\nCo-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>\\n' >> "$1"
17
+ fi
18
+ fi
19
+ `;
20
+
21
+ function hookPathFor(root) {
22
+ return path.join(root, '.git', 'hooks', 'prepare-commit-msg');
23
+ }
24
+
25
+ // Install the marked block into .git/hooks/prepare-commit-msg. Returns { hookPath, already }.
26
+ function installSignHook(root = process.cwd()) {
27
+ if (!fs.existsSync(path.join(root, '.git'))) throw new Error('not a git repo (no .git here)');
28
+ const hookPath = hookPathFor(root);
29
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
30
+ let content = '';
31
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch {}
32
+ if (content.includes(MARKER)) return { hookPath, already: true };
33
+ if (!content) content = '#!/bin/sh\n';
34
+ if (!content.endsWith('\n')) content += '\n';
35
+ content += HOOK_BLOCK;
36
+ fs.writeFileSync(hookPath, content);
37
+ fs.chmodSync(hookPath, 0o755);
38
+ return { hookPath, already: false };
39
+ }
40
+
41
+ // Remove only our marked block; leave the rest of the hook untouched.
42
+ function removeSignHook(root = process.cwd()) {
43
+ const hookPath = hookPathFor(root);
44
+ let content = '';
45
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch { return { hookPath, removed: false }; }
46
+ if (!content.includes(MARKER)) return { hookPath, removed: false };
47
+ const cleaned = content.replace(HOOK_BLOCK, '');
48
+ if (cleaned.trim() === '#!/bin/sh') {
49
+ fs.unlinkSync(hookPath);
50
+ } else {
51
+ fs.writeFileSync(hookPath, cleaned);
52
+ }
53
+ return { hookPath, removed: true };
54
+ }
55
+
56
+ function signStatus(root = process.cwd()) {
57
+ const hookPath = hookPathFor(root);
58
+ let content = '';
59
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch {}
60
+ return { hookPath, installed: content.includes(MARKER) };
61
+ }
62
+
63
+ function signCommand(sub) {
64
+ const rel = (p) => path.relative(process.cwd(), p);
65
+ if (sub === 'off' || sub === 'remove') {
66
+ const { hookPath, removed } = removeSignHook();
67
+ console.log(removed
68
+ ? `\n ✓ atris co-author removed: ${rel(hookPath)}\n`
69
+ : `\n nothing to remove — atris co-author is not installed\n`);
70
+ return 0;
71
+ }
72
+ if (sub === 'status') {
73
+ const { hookPath, installed } = signStatus();
74
+ console.log(installed
75
+ ? `\n ✓ atris co-author is on: ${rel(hookPath)}\n every commit gets: ${TRAILER}\n`
76
+ : `\n atris co-author is off — run 'atris sign' to turn it on\n`);
77
+ return installed ? 0 : 1;
78
+ }
79
+ if (sub && sub !== 'on' && sub !== 'install') {
80
+ console.log(`\n atris sign — co-author trailer on every commit\n\n atris sign install the prepare-commit-msg hook\n atris sign off remove it\n atris sign status check whether it's installed\n\n While installed, any commit in a repo with an atris/ folder gets:\n ${TRAILER}\n`);
81
+ return 0;
82
+ }
83
+ const { hookPath, already } = installSignHook();
84
+ console.log(already
85
+ ? `\n already installed: ${rel(hookPath)}\n`
86
+ : `\n ✓ atris co-author installed: ${rel(hookPath)}\n every commit here now gets: ${TRAILER}\n`);
87
+ return 0;
88
+ }
89
+
90
+ module.exports = { signCommand, installSignHook, removeSignHook, signStatus, TRAILER };
package/commands/task.js CHANGED
@@ -8,13 +8,14 @@ const http = require('http');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const { taskProofState, buildVerifiedProof } = require('../lib/task-proof');
11
- const { evaluateAutoAccept, parseVerifyCommand } = require('../lib/auto-accept-certified');
11
+ const { evaluateAutoAccept, parseVerifyCommand, runVerifyCommand, DENIED_TAGS } = require('../lib/auto-accept-certified');
12
12
  const { extractReceiptEvidence } = require('../lib/receipt-evidence');
13
13
  const escapeRegExp = require('../lib/escape-regexp');
14
14
  const {
15
15
  normalizeOwnerSlug,
16
16
  resolveFunctionalOwner: resolveFunctionalTaskOwner,
17
17
  } = require('../lib/functional-owner');
18
+ const { operatorReady, hasAgentJargon } = require('./autoland');
18
19
 
19
20
  const DEFAULT_OWNER = process.env.ATRIS_AGENT_ID
20
21
  || process.env.USER
@@ -90,6 +91,14 @@ function getTaskDb() {
90
91
  }
91
92
  }
92
93
 
94
+ function warnIfTaskTitleNeedsOperatorWhy(title) {
95
+ const text = String(title || '').trim();
96
+ if (!text || operatorReady(text)) return null;
97
+ const warning = 'Warning: add the why in plain words to this task title: what it buys or costs, who benefits, and no flags or identifiers.';
98
+ console.error(warning);
99
+ return warning;
100
+ }
101
+
93
102
  function taskUsageText() {
94
103
  return `
95
104
  atris task - durable local task state (SQLite, gitignored)
@@ -113,6 +122,8 @@ atris task - durable local task state (SQLite, gitignored)
113
122
  atris task review-chat <id> [--as <owner>] Start a task-owned /codex verification chat
114
123
  atris task accept <id> [--proof "..."] [--public]
115
124
  Human accepts proof, marks done; --public also publishes AgentXP
125
+ atris task certify-verified [--dry-run] [--limit <n>] [--as <actor>]
126
+ Re-run the runnable check named in each Review proof as a second actor; passing rows certify (denied lanes and check-less rows wait for a human)
116
127
  atris task auto-accept-certified --dry-run [--strict-verify] [--limit <n>]
117
128
  Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
118
129
  atris task revise <id> --note "..." Send reviewed work back to Do
@@ -374,13 +385,36 @@ function textFlag(args, names) {
374
385
 
375
386
  function landingFlags(args) {
376
387
  return {
377
- happened: textFlag(args, ['--happened', '--what-happened']),
388
+ happened: textFlag(args, ['--landing', '--happened', '--what-happened']),
378
389
  checked: textFlag(args, ['--checked', '--how-checked']),
379
390
  tested: textFlag(args, ['--tested', '--what-tested']),
380
391
  decision: textFlag(args, ['--decision', '--acceptance']),
381
392
  };
382
393
  }
383
394
 
395
+ function normalizedLandingSentence(value) {
396
+ return String(value || '').replace(/\s+/g, ' ').trim();
397
+ }
398
+
399
+ function defaultLandingSentenceForTitle(title) {
400
+ return `Completed: ${String(title || '').trim()}.`;
401
+ }
402
+
403
+ function landingNeedsDayOnePm(sentence, title) {
404
+ const text = normalizedLandingSentence(sentence);
405
+ if (!text) return true;
406
+ if (text.toLowerCase() === normalizedLandingSentence(defaultLandingSentenceForTitle(title)).toLowerCase()) return true;
407
+ return hasAgentJargon(text) || !operatorReady(text);
408
+ }
409
+
410
+ function warnIfLandingNeedsDayOnePm(landing, title) {
411
+ const sentence = landing && typeof landing === 'object' ? landing.happened : '';
412
+ if (!landingNeedsDayOnePm(sentence, title)) return null;
413
+ const warning = 'Advisory: add --landing with one capability sentence a day-one PM could read, with the result in plain words and no flags or identifiers.';
414
+ console.error(warning);
415
+ return warning;
416
+ }
417
+
384
418
  function numericFlag(args, name) {
385
419
  const value = flag(args, name);
386
420
  if (value === null || value === true || value === undefined) return null;
@@ -597,18 +631,19 @@ function buildPendingReviewChatPredicate(taskDb, db, workspaceRoot) {
597
631
  function createReviewNextTask(taskDb, db, currentTask, title) {
598
632
  const nextTitle = String(title || '').trim();
599
633
  if (!nextTitle) return null;
634
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(nextTitle);
600
635
  const currentMetadata = currentTask && currentTask.metadata && typeof currentTask.metadata === 'object'
601
636
  ? currentTask.metadata
602
637
  : {};
603
638
  const existing = findExistingReviewNextTask(taskDb, db, currentTask, nextTitle);
604
- if (existing) return { id: existing.id, inserted: false };
639
+ if (existing) return { id: existing.id, inserted: false, operator_title_warning: operatorTitleWarning };
605
640
  const goalId = currentMetadata.goal_id || currentMetadata.goalId || null;
606
641
  const parentId = currentTask && currentTask.id || null;
607
642
  const sourceKey = parentId && typeof taskDb.sourceKey === 'function'
608
643
  ? taskDb.sourceKey(`task_review_next:${parentId}`, nextTitle)
609
644
  : null;
610
645
  try {
611
- return taskDb.addTask(db, {
646
+ const created = taskDb.addTask(db, {
612
647
  title: nextTitle,
613
648
  tag: currentTask && currentTask.tag || null,
614
649
  workspaceRoot: taskDb.workspaceRoot(),
@@ -619,10 +654,11 @@ function createReviewNextTask(taskDb, db, currentTask, title) {
619
654
  source: 'task_review_next',
620
655
  },
621
656
  });
657
+ return { ...created, operator_title_warning: operatorTitleWarning };
622
658
  } catch (error) {
623
659
  if (sourceKey && /constraint|unique/i.test(String(error && (error.code || error.message) || error))) {
624
660
  const racedExisting = findExistingReviewNextTask(taskDb, db, currentTask, nextTitle);
625
- if (racedExisting) return { id: racedExisting.id, inserted: false };
661
+ if (racedExisting) return { id: racedExisting.id, inserted: false, operator_title_warning: operatorTitleWarning };
626
662
  }
627
663
  throw error;
628
664
  }
@@ -4885,6 +4921,7 @@ function cmdAdd(args) {
4885
4921
  const taskDb = getTaskDb();
4886
4922
  const db = taskDb.open();
4887
4923
  const ws = taskDb.workspaceRoot();
4924
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
4888
4925
  // Generation throttle — the named root cause is generation > human-review rate. An AGENT cannot keep
4889
4926
  // minting tasks while a wall of certified-but-unaccepted work waits; that treadmill is what accept-group
4890
4927
  // only drains. Humans and --force bypass. Closes the tap instead of just enlarging the bucket.
@@ -4910,6 +4947,7 @@ function cmdAdd(args) {
4910
4947
  action: 'created',
4911
4948
  task_id: result.id,
4912
4949
  inserted: result.inserted !== false,
4950
+ operator_title_warning: operatorTitleWarning,
4913
4951
  projection_path: outPath,
4914
4952
  task,
4915
4953
  });
@@ -4952,6 +4990,7 @@ function cmdDelegate(args) {
4952
4990
  const taskDb = getTaskDb();
4953
4991
  const db = taskDb.open();
4954
4992
  const ws = taskDb.workspaceRoot();
4993
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
4955
4994
  const ownerResolution = resolveFunctionalTaskOwner({
4956
4995
  requestedOwner: requestedOwner && requestedOwner !== true ? requestedOwner : null,
4957
4996
  title,
@@ -5007,6 +5046,7 @@ function cmdDelegate(args) {
5007
5046
  executed_by: executedBy || null,
5008
5047
  via,
5009
5048
  handoff,
5049
+ operator_title_warning: operatorTitleWarning,
5010
5050
  projection_path: outPath,
5011
5051
  task,
5012
5052
  });
@@ -5271,6 +5311,7 @@ function buildEndgameTaskSeed({ slug, horizon, owner }) {
5271
5311
 
5272
5312
  function createEndgameSeedTask(taskDb, db, seed, owner) {
5273
5313
  const taskOwner = String(owner || DEFAULT_OWNER);
5314
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(seed.title);
5274
5315
  const result = taskDb.addTask(db, {
5275
5316
  title: seed.title,
5276
5317
  tag: seed.tag,
@@ -5294,6 +5335,7 @@ function createEndgameSeedTask(taskDb, db, seed, owner) {
5294
5335
  ok: true,
5295
5336
  task_id: result.id,
5296
5337
  inserted: result.inserted !== false,
5338
+ operator_title_warning: operatorTitleWarning,
5297
5339
  note_version: note.event.version,
5298
5340
  };
5299
5341
  }
@@ -5367,6 +5409,7 @@ function cmdNext(args) {
5367
5409
  handoff,
5368
5410
  next_agent_action: nextAgentAction,
5369
5411
  note_version: created.note_version,
5412
+ operator_title_warning: created.operator_title_warning || null,
5370
5413
  task: createdTask,
5371
5414
  review_task: reviewTask,
5372
5415
  });
@@ -5445,6 +5488,9 @@ function cmdNext(args) {
5445
5488
  }
5446
5489
  console.log(`next ${taskRef(compactTaskFromProjection(projection, open[0].id))} @${owner}`);
5447
5490
  console.log(open[0].title);
5491
+ const ref = taskRef(compactTaskFromProjection(projection, open[0].id));
5492
+ const { printOperatorNext } = require('../lib/operator-next');
5493
+ printOperatorNext(`atris task step ${ref}`);
5448
5494
  }
5449
5495
 
5450
5496
  function continueWorkForReviewTask(taskDb, db, taskId, { owner = DEFAULT_OWNER } = {}) {
@@ -5488,6 +5534,7 @@ function continueWorkForReviewTask(taskDb, db, taskId, { owner = DEFAULT_OWNER }
5488
5534
  parent_task_id: taskId,
5489
5535
  next_task_id: nextCreated ? nextCreated.id : null,
5490
5536
  created: Boolean(nextCreated && nextCreated.inserted !== false),
5537
+ operator_title_warning: nextCreated ? nextCreated.operator_title_warning || null : null,
5491
5538
  owner: String(owner || DEFAULT_OWNER),
5492
5539
  projection_path: outPath,
5493
5540
  parent,
@@ -7549,6 +7596,7 @@ function cmdFinish(args) {
7549
7596
  const currentTask = taskDb.getTask(db, taskId);
7550
7597
  const actor = String(flag(args, '--as') || DEFAULT_OWNER);
7551
7598
  const proof = proofFlagValue(args);
7599
+ const landing = landingFlags(args);
7552
7600
  const failed = hasFlag(args, '--failed');
7553
7601
  const hasReview = hasFlag(args, '--review') || flag(args, '--lesson') || flag(args, '--next') || flag(args, '--proof') || flag(args, '--reward');
7554
7602
  if (agentProofOnlyMode() && !failed) {
@@ -7585,6 +7633,7 @@ function cmdFinish(args) {
7585
7633
  process.exit(1);
7586
7634
  }
7587
7635
  if (hasReview) {
7636
+ const landingAdvisory = !failed ? warnIfLandingNeedsDayOnePm(landing, currentTask && currentTask.title) : null;
7588
7637
  const result = taskDb.reviewTask(db, {
7589
7638
  id: taskId,
7590
7639
  actor,
@@ -7593,6 +7642,7 @@ function cmdFinish(args) {
7593
7642
  nextTask: typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '',
7594
7643
  proof,
7595
7644
  careerXpEligible: false,
7645
+ landing,
7596
7646
  });
7597
7647
  const nextCreated = createNextTaskIfRequested(taskDb, db, args, currentTask, result.episode.next_task_suggestion);
7598
7648
  const xpProjection = refreshCareerXpAfterReview(result);
@@ -7606,6 +7656,7 @@ function cmdFinish(args) {
7606
7656
  reward: result.episode.reward.value,
7607
7657
  episode: result.episode,
7608
7658
  xp_projection: xpProjection,
7659
+ landing_advisory: landingAdvisory,
7609
7660
  next_task_id: nextCreated ? nextCreated.id : null,
7610
7661
  projection_path: outPath,
7611
7662
  task: compactTaskFromProjection(projection, taskId),
@@ -7700,6 +7751,7 @@ function cmdReady(args) {
7700
7751
  console.error(`ready failed: ${result.reason}`);
7701
7752
  process.exit(1);
7702
7753
  }
7754
+ const landingAdvisory = warnIfLandingNeedsDayOnePm(landing, result.row && result.row.title);
7703
7755
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
7704
7756
  const agentCertified = result.event.payload.agent_certified === true;
7705
7757
  const projectionTask = taskFromProjection(projection, taskId)
@@ -7741,6 +7793,7 @@ function cmdReady(args) {
7741
7793
  agent_certified: agentCertified,
7742
7794
  handoff,
7743
7795
  result_trace: resultTrace,
7796
+ landing_advisory: landingAdvisory,
7744
7797
  ...(nextTaskInput.ignored ? { review_next_task_ignored: nextTaskInput.ignored } : {}),
7745
7798
  projection_path: outPath,
7746
7799
  task: compactTaskFromProjection(projection, taskId),
@@ -7909,6 +7962,151 @@ function acceptReviewTask(taskDb, db, taskId, { actor, proof, reward, lesson = '
7909
7962
  return { ok: true, reviewed };
7910
7963
  }
7911
7964
 
7965
+ // Second-actor certification for proof-backed Review rows whose proof names a
7966
+ // runnable, allowlisted check. Re-running that check as a distinct actor IS
7967
+ // the independent verification the certification gate asks for — the row
7968
+ // becomes certified and autoland can land it on the same heartbeat. Rows in
7969
+ // denied lanes, without a runnable check, or already certified keep their
7970
+ // existing paths (review chats, human accept).
7971
+ function certifyVerifyCandidate(task) {
7972
+ const metadata = task.metadata || {};
7973
+ const review = task.review || {};
7974
+ const recorded = typeof metadata.verify === 'string' && metadata.verify.trim() ? [metadata.verify.trim()] : [];
7975
+ const proof = String(review.proof || metadata.latest_agent_proof || '');
7976
+ for (const raw of [...recorded, ...taskReviewEvidenceCommands(proof)]) {
7977
+ // The evidence extractor can keep trailing prose ("git diff --check. Evidence
7978
+ // inspected: ..."); try the full clause, then the first sentence of it.
7979
+ for (const candidate of [raw, raw.split(/\.(?:\s|$)/)[0]].map((c) => String(c || '').trim())) {
7980
+ if (candidate && parseVerifyCommand(candidate).ok) return candidate;
7981
+ }
7982
+ }
7983
+ return null;
7984
+ }
7985
+
7986
+ function stampCertifyVerifyMetadata(taskDb, db, taskId, actor, verify) {
7987
+ const row = taskDb.getTask(db, taskId);
7988
+ if (!row) return;
7989
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
7990
+ metadata.verify = metadata.verify || verify;
7991
+ metadata.certified_verified_at = new Date().toISOString();
7992
+ metadata.certified_verified_by = actor;
7993
+ db.prepare(`
7994
+ UPDATE tasks
7995
+ SET metadata = ?,
7996
+ updated_at = ?
7997
+ WHERE id = ?
7998
+ `).run(JSON.stringify(metadata), Date.now(), taskId);
7999
+ }
8000
+
8001
+ function cmdCertifyVerified(args) {
8002
+ const dryRun = hasFlag(args, '--dry-run');
8003
+ const asJson = wantsJson(args);
8004
+ const actor = String(flag(args, '--as') || 'autoland-verifier');
8005
+ const limitRaw = flag(args, '--limit');
8006
+ const max = limitRaw && limitRaw !== true ? Math.max(1, Number(limitRaw) || 6) : 6;
8007
+
8008
+ const taskDb = getTaskDb();
8009
+ const db = taskDb.open();
8010
+ const { projection } = writeDefaultProjection(taskDb, db);
8011
+ const candidates = (projection.tasks || [])
8012
+ .filter((t) => t && t.status === 'review')
8013
+ .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))
8014
+ .slice(0, max);
8015
+ const results = [];
8016
+ for (const item of candidates) {
8017
+ const fullProjection = enrichTaskProjection(taskDb.taskProjection(db, { taskId: item.id }));
8018
+ const task = fullProjection.tasks[0] || null;
8019
+ if (!task) {
8020
+ results.push({ ref: item.display_id || item.id, action: 'skipped', reason: 'task_not_found' });
8021
+ continue;
8022
+ }
8023
+ const ref = task.display_id || task.legacy_ref || task.id;
8024
+ const metadata = task.metadata || {};
8025
+ const review = task.review || {};
8026
+ if (task.status !== 'review') {
8027
+ results.push({ ref, action: 'skipped', reason: 'not_in_review' });
8028
+ continue;
8029
+ }
8030
+ const approval = String(review.approval_status || metadata.approval_status || 'pending').toLowerCase();
8031
+ if (approval !== 'pending') {
8032
+ results.push({ ref, action: 'skipped', reason: `approval_${approval}` });
8033
+ continue;
8034
+ }
8035
+ const tag = String(task.tag || '').toLowerCase();
8036
+ if (DENIED_TAGS.has(tag)) {
8037
+ results.push({ ref, action: 'skipped', reason: `denied_tag_${tag}` });
8038
+ continue;
8039
+ }
8040
+ // Skip only rows the accept lane can already land, or rows blocked by
8041
+ // something an executed second-actor check cannot cure. A row with two
8042
+ // passes from ONE actor is exactly what this command exists to cure —
8043
+ // "certified" alone is not landable.
8044
+ const evaluation = evaluateAutoAccept(task, { strictVerify: false });
8045
+ if (evaluation.eligible) {
8046
+ results.push({ ref, action: 'skipped', reason: 'already_landable' });
8047
+ continue;
8048
+ }
8049
+ const curable = ['not_agent_certified', 'needs_second_reviewer_or_third_pass', 'insufficient_review_passes'];
8050
+ if (!curable.includes(evaluation.reason)) {
8051
+ results.push({ ref, action: 'skipped', reason: evaluation.reason });
8052
+ continue;
8053
+ }
8054
+ const verify = certifyVerifyCandidate(task);
8055
+ if (!verify) {
8056
+ results.push({ ref, action: 'skipped', reason: 'no_runnable_check_in_proof' });
8057
+ continue;
8058
+ }
8059
+ if (dryRun) {
8060
+ results.push({ ref, action: 'would_certify', verify });
8061
+ continue;
8062
+ }
8063
+ const run = runVerifyCommand(verify, task.workspace_root || process.cwd());
8064
+ if (!run.ok) {
8065
+ results.push({ ref, action: 'verify_failed', reason: run.reason, verify });
8066
+ continue;
8067
+ }
8068
+ const builderProof = String(review.proof || metadata.latest_agent_proof || '').slice(0, 200);
8069
+ const readied = taskDb.readyTask(db, {
8070
+ id: task.id,
8071
+ actor,
8072
+ proof: `Second-actor check: \`${verify}\` re-run by ${actor}, exited 0. Builder proof inspected: ${builderProof}`,
8073
+ lesson: '',
8074
+ nextTask: '',
8075
+ });
8076
+ if (!readied.ready) {
8077
+ results.push({ ref, action: 'certify_failed', reason: readied.reason, verify });
8078
+ continue;
8079
+ }
8080
+ stampCertifyVerifyMetadata(taskDb, db, task.id, actor, verify);
8081
+ const certifiedNow = readied.event?.payload?.agent_certified === true;
8082
+ results.push({ ref, action: certifiedNow ? 'certified' : 'pass_recorded', verify });
8083
+ }
8084
+ const { outPath } = writeDefaultProjection(taskDb, db);
8085
+ const certified = results.filter((r) => r.action === 'certified').length;
8086
+ const payload = {
8087
+ ok: true,
8088
+ action: dryRun ? 'certify_verified_dry_run' : 'certify_verified',
8089
+ actor,
8090
+ certified,
8091
+ would_certify: results.filter((r) => r.action === 'would_certify').length,
8092
+ skipped: results.filter((r) => r.action === 'skipped').length,
8093
+ failed: results.filter((r) => r.action === 'verify_failed' || r.action === 'certify_failed').length,
8094
+ results,
8095
+ projection_path: outPath,
8096
+ };
8097
+ if (asJson) {
8098
+ console.log(JSON.stringify(payload, null, 2));
8099
+ } else if (results.length === 0) {
8100
+ console.log('certify-verified: no review rows to consider.');
8101
+ } else {
8102
+ for (const r of results) {
8103
+ console.log(`${r.action.padEnd(14)} ${r.ref}${r.verify ? ` \`${r.verify}\`` : ''}${r.reason ? ` (${r.reason})` : ''}`);
8104
+ }
8105
+ console.log(`certified ${certified}; humans keep denied lanes and rows without a runnable check.`);
8106
+ }
8107
+ return payload;
8108
+ }
8109
+
7912
8110
  function cmdAutoAcceptCertified(args) {
7913
8111
  const dryRun = hasFlag(args, '--dry-run');
7914
8112
  const strictVerify = hasFlag(args, '--strict-verify');
@@ -9256,13 +9454,14 @@ async function handleTaskApi(req, res, taskDb, db) {
9256
9454
  const body = await readJsonBody(req);
9257
9455
  const title = String(body.title || '').trim();
9258
9456
  if (!title) return sendJson(res, 400, { ok: false, reason: 'missing_title', detail: 'title required' });
9457
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
9259
9458
  const result = taskDb.addTask(db, {
9260
9459
  title,
9261
9460
  tag: body.tag ? String(body.tag) : 'tasks',
9262
9461
  workspaceRoot: taskDb.workspaceRoot(),
9263
9462
  });
9264
9463
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
9265
- return sendJson(res, 200, { ok: true, action: 'created', task_id: result.id, projection_path: outPath, task: taskFromProjection(projection, result.id) });
9464
+ return sendJson(res, 200, { ok: true, action: 'created', task_id: result.id, operator_title_warning: operatorTitleWarning, projection_path: outPath, task: taskFromProjection(projection, result.id) });
9266
9465
  }
9267
9466
  if (req.method === 'POST' && url.pathname === '/api/tasks/clear-plan') {
9268
9467
  const body = await readJsonBody(req);
@@ -9769,6 +9968,8 @@ async function run(args) {
9769
9968
  case 'auto-accept-certified':
9770
9969
  case 'auto-accept':
9771
9970
  return cmdAutoAcceptCertified(rest);
9971
+ case 'certify-verified':
9972
+ return cmdCertifyVerified(rest);
9772
9973
  case 'accept-group':
9773
9974
  return cmdAcceptGroup(rest);
9774
9975
  case 'revise': return cmdRevise(rest);
package/commands/truth.js CHANGED
@@ -38,7 +38,8 @@ function loadMissions(cwd) {
38
38
  if (id) seen.set(id, rec);
39
39
  } catch { /* skip bad lines */ }
40
40
  }
41
- return [...seen.values()].filter((m) => m.status && m.status !== 'complete' && m.status !== 'archived');
41
+ // stopped = killed-on-purpose = a closed loop; only genuinely open states count
42
+ return [...seen.values()].filter((m) => m.status && !['complete', 'archived', 'stopped'].includes(m.status));
42
43
  }
43
44
 
44
45
  function loadTaskCounts() {