atris 3.37.0 → 3.38.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/README.md +2 -0
- package/atris/PERSONA.md +3 -7
- package/atris.md +10 -2
- package/ax +158 -33
- package/bin/atris.js +49 -2
- package/commands/activate.js +16 -3
- package/commands/agent-spawn.js +0 -7
- package/commands/autoland.js +92 -14
- package/commands/autopilot-front.js +0 -4
- package/commands/autopilot.js +0 -12
- package/commands/brain.js +0 -7
- package/commands/brief.js +0 -2
- package/commands/business-sync.js +0 -1
- package/commands/business.js +107 -12
- package/commands/clean.js +31 -2
- package/commands/close.js +46 -11
- package/commands/cloud.js +0 -1
- package/commands/codex-goal.js +0 -2
- package/commands/computer.js +6 -5
- package/commands/console.js +46 -9
- package/commands/deck.js +29 -8
- package/commands/dream.js +0 -7
- package/commands/drill.js +0 -1
- package/commands/engine.js +10 -18
- package/commands/experiments.js +0 -4
- package/commands/fleet-report.js +12 -4
- package/commands/game.js +261 -0
- package/commands/gm.js +10 -2
- package/commands/goal.js +0 -6
- package/commands/harvest.js +0 -1
- package/commands/improve.js +29 -8
- package/commands/init.js +18 -34
- package/commands/integrations.js +0 -7
- package/commands/land.js +71 -8
- package/commands/launchpad.js +0 -2
- package/commands/lesson.js +17 -7
- package/commands/loop-front.js +0 -2
- package/commands/loops.js +6 -5
- package/commands/meet.js +0 -2
- package/commands/member.js +68 -3
- package/commands/mission.js +359 -102
- package/commands/next.js +0 -1
- package/commands/now.js +0 -12
- package/commands/one-lap.js +8 -8
- package/commands/orb.js +0 -4
- package/commands/pack-craft.js +0 -1
- package/commands/pack.js +670 -77
- package/commands/play.js +0 -2
- package/commands/probe.js +10 -4
- package/commands/pulse.js +73 -48
- package/commands/push.js +0 -4
- package/commands/radar.js +14 -11
- package/commands/recover.js +340 -0
- package/commands/report.js +0 -1
- package/commands/router.js +319 -0
- package/commands/run-front.js +0 -2
- package/commands/search.js +0 -3
- package/commands/skill.js +7 -2
- package/commands/slop.js +225 -7
- package/commands/status.js +6 -0
- package/commands/stream.js +0 -2
- package/commands/study.js +0 -3
- package/commands/sync-checkout.js +51 -0
- package/commands/sync.js +11 -40
- package/commands/task.js +125 -39
- package/commands/teach.js +86 -0
- package/commands/unknowns.js +0 -10
- package/commands/update.js +0 -1
- package/commands/visualize.js +0 -5
- package/commands/watch.js +0 -6
- package/commands/wiki.js +41 -11
- package/commands/wish.js +0 -1
- package/commands/worktree.js +87 -14
- package/commands/xp.js +1 -6
- package/commands/youtube.js +0 -2
- package/lib/accept-verify-gate.js +122 -0
- package/lib/activity-stream.js +0 -2
- package/lib/auto-accept-certified.js +145 -2
- package/lib/autoland.js +0 -3
- package/lib/ax-auto-lane.js +94 -10
- package/lib/bench/context.js +0 -2
- package/lib/bench/report.js +0 -1
- package/lib/bench/runner.js +0 -11
- package/lib/brief-ledger.js +0 -6
- package/lib/business-simulate.js +382 -0
- package/lib/chat-log-scan.js +0 -5
- package/lib/checkout-sync.js +52 -0
- package/lib/claude-boot-block.js +48 -0
- package/lib/cloud-mission.js +0 -6
- package/lib/codex-flight.js +0 -1
- package/lib/conductor-artifacts.js +25 -0
- package/lib/context-gatherer.js +0 -1
- package/lib/deck-compose.js +0 -1
- package/lib/deck-history.js +0 -1
- package/lib/deck-layout.js +0 -5
- package/lib/deck-review.js +0 -7
- package/lib/deck-schema.js +0 -1
- package/lib/default-verifier.js +0 -2
- package/lib/engine-registry.js +16 -8
- package/lib/experiments/daily.js +0 -6
- package/lib/fleet.js +210 -27
- package/lib/functional-owner.js +0 -3
- package/lib/inspect-fields.js +0 -1
- package/lib/known-commands.js +2 -2
- package/lib/learnings.js +0 -2
- package/lib/loop-doctor.js +11 -17
- package/lib/manifest.js +0 -2
- package/lib/member-alive.js +0 -1
- package/lib/member-switches.js +0 -5
- package/lib/mission-artifact.js +0 -1
- package/lib/mission-protected-lane.js +293 -0
- package/lib/mission-room.js +0 -2
- package/lib/mission-runtime-loop.js +0 -1
- package/lib/next-moves.js +0 -18
- package/lib/official-cli-integration.js +0 -4
- package/lib/orb-context.js +6 -3
- package/lib/orb-scorecard.js +0 -1
- package/lib/outbound-send-gate.js +0 -6
- package/lib/permission-grants.js +0 -6
- package/lib/policy-lessons.js +0 -2
- package/lib/pulse.js +68 -15
- package/lib/review-integrity.js +0 -2
- package/lib/router-brain.js +0 -2
- package/lib/runner-command.js +0 -4
- package/lib/runtime-bootstrap.js +0 -1
- package/lib/scorecard.js +0 -1
- package/lib/self-drive.js +1 -1
- package/lib/state-detection.js +0 -6
- package/lib/task-db.js +0 -4
- package/lib/team-presence.js +0 -1
- package/lib/voice-gate.js +63 -2
- package/lib/wiki.js +152 -9
- package/lib/wish-audit.js +20 -6
- package/lib/wish-delegate.js +26 -20
- package/lib/wish-stats.js +0 -5
- package/lib/wish-store.js +0 -1
- package/lib/workspace-safety.js +0 -2
- package/lib/zip.js +0 -1
- package/package.json +1 -1
package/commands/worktree.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
7
|
+
const { isConductorArtifact } = require('../lib/conductor-artifacts');
|
|
7
8
|
|
|
8
9
|
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
9
10
|
const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
@@ -261,11 +262,16 @@ function baseBranchName(ref) {
|
|
|
261
262
|
return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
function statusCounts(root, {
|
|
265
|
+
function statusCounts(root, {
|
|
266
|
+
ignoredUnstagedFiles = new Set(),
|
|
267
|
+
ignoredUntrackedFiles = new Set(),
|
|
268
|
+
ignoreUntracked = null,
|
|
269
|
+
} = {}) {
|
|
265
270
|
if (!fs.existsSync(root)) return null;
|
|
266
271
|
// -uall expands untracked directories to individual files so ignoredUntrackedFiles
|
|
267
|
-
// can match exact paths (plain porcelain collapses them to "?? dir/").
|
|
268
|
-
const
|
|
272
|
+
// and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
|
|
273
|
+
const expandUntracked = ignoredUntrackedFiles.size || typeof ignoreUntracked === 'function';
|
|
274
|
+
const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
|
|
269
275
|
const result = runGit(statusArgs, { cwd: root, check: false });
|
|
270
276
|
if (result.status !== 0) return null;
|
|
271
277
|
let staged = 0;
|
|
@@ -276,6 +282,7 @@ function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntracked
|
|
|
276
282
|
if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
|
|
277
283
|
if (line.startsWith('??')) {
|
|
278
284
|
if (ignoredUntrackedFiles.has(file)) continue;
|
|
285
|
+
if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
|
|
279
286
|
untracked += 1;
|
|
280
287
|
continue;
|
|
281
288
|
}
|
|
@@ -285,6 +292,16 @@ function statusCounts(root, { ignoredUnstagedFiles = new Set(), ignoredUntracked
|
|
|
285
292
|
return { staged, unstaged, untracked };
|
|
286
293
|
}
|
|
287
294
|
|
|
295
|
+
// Remove conductor plumbing from the index after a broad `git add -A`, so the commit
|
|
296
|
+
// records engine output only. Paths that were never staged are simply not listed.
|
|
297
|
+
function unstageConductorArtifacts(root) {
|
|
298
|
+
const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
|
|
299
|
+
if (staged.status !== 0) return [];
|
|
300
|
+
const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean).filter(isConductorArtifact);
|
|
301
|
+
if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
|
|
302
|
+
return drop;
|
|
303
|
+
}
|
|
304
|
+
|
|
288
305
|
function changedFiles(root, args) {
|
|
289
306
|
const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
|
|
290
307
|
if (result.status !== 0) return new Set();
|
|
@@ -472,6 +489,14 @@ function createOrFindPr(root, branch, targetRef, title, dryRun) {
|
|
|
472
489
|
`Target: ${targetBranch}`,
|
|
473
490
|
].join('\n');
|
|
474
491
|
if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
|
|
492
|
+
// An engine that produced nothing leaves a branch level with its base. Asking
|
|
493
|
+
// GitHub for a PR there fails with "No commits between ...", and that throw used
|
|
494
|
+
// to escape the flight and abort the whole run. Say it plainly instead.
|
|
495
|
+
const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
|
|
496
|
+
if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
|
|
497
|
+
console.log('pr: skipped (no commits ahead of base)');
|
|
498
|
+
return '';
|
|
499
|
+
}
|
|
475
500
|
const created = spawnSync(
|
|
476
501
|
'gh',
|
|
477
502
|
['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
|
|
@@ -498,6 +523,36 @@ function shipHelp() {
|
|
|
498
523
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
499
524
|
}
|
|
500
525
|
|
|
526
|
+
function deniedLaneForShip(root, branch, message) {
|
|
527
|
+
try {
|
|
528
|
+
const { DENIED_TAGS } = require('../lib/fleet');
|
|
529
|
+
const refs = new Set();
|
|
530
|
+
for (const source of [String(branch || ''), String(message || '')]) {
|
|
531
|
+
for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
|
|
532
|
+
for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
|
|
533
|
+
}
|
|
534
|
+
if (!refs.size) return null;
|
|
535
|
+
const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
|
|
536
|
+
if (!fs.existsSync(projectionPath)) return null;
|
|
537
|
+
const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
|
|
538
|
+
for (const task of projection.tasks || []) {
|
|
539
|
+
const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
|
|
540
|
+
if (!ids.some(id => refs.has(id))) continue;
|
|
541
|
+
const tags = [task.tag, ...(task.metadata?.todo_tags || [])].filter(Boolean).map(t => String(t).toLowerCase());
|
|
542
|
+
const hit = tags.find(t => DENIED_TAGS.includes(t));
|
|
543
|
+
if (hit) return { taskId: ids[0], tag: hit };
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function findPrimaryRoot(root) {
|
|
552
|
+
const worktrees = listWorktrees(root);
|
|
553
|
+
return worktrees[0]?.path || root;
|
|
554
|
+
}
|
|
555
|
+
|
|
501
556
|
function shipWorktree(args) {
|
|
502
557
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
503
558
|
shipHelp();
|
|
@@ -531,6 +586,17 @@ function shipWorktree(args) {
|
|
|
531
586
|
console.error('blocked: ship from an isolated worktree, not the primary checkout');
|
|
532
587
|
return 2;
|
|
533
588
|
}
|
|
589
|
+
// Denied lanes are checked at dispatch, but a flight that was staffed
|
|
590
|
+
// before a task entered a denied lane (or killed mid-flight) could still
|
|
591
|
+
// ship: a payments command self-landed twice this way on 2026-07-26. The
|
|
592
|
+
// ship gate is the last door, so it re-checks.
|
|
593
|
+
if (!hasFlag(args, '--allow-denied-lane')) {
|
|
594
|
+
const denied = deniedLaneForShip(root, branch, message);
|
|
595
|
+
if (denied) {
|
|
596
|
+
console.error(`blocked: this branch carries ${denied.taskId} which sits in the protected ${denied.tag} lane; a human lands it (or pass --allow-denied-lane on written order)`);
|
|
597
|
+
return 2;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
534
600
|
|
|
535
601
|
let oneLapProof = null;
|
|
536
602
|
try {
|
|
@@ -552,7 +618,10 @@ function shipWorktree(args) {
|
|
|
552
618
|
refreshRemoteRef(root, targetRef);
|
|
553
619
|
const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
|
|
554
620
|
const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
|
|
555
|
-
|
|
621
|
+
// Conductor plumbing must not make the checkout look dirty, or a flight where the
|
|
622
|
+
// engine wrote nothing commits the fleet's own prompt file and calls it work.
|
|
623
|
+
const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorArtifact })
|
|
624
|
+
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
556
625
|
const dirty = counts.staged || counts.unstaged || counts.untracked;
|
|
557
626
|
if (dirty && !message) {
|
|
558
627
|
console.error('blocked: --message is required when there are local changes to commit');
|
|
@@ -563,6 +632,9 @@ function shipWorktree(args) {
|
|
|
563
632
|
console.log(`commit: ${message}`);
|
|
564
633
|
if (!dryRun) {
|
|
565
634
|
runGit(['add', '-A'], { cwd: root });
|
|
635
|
+
// `add -A` sweeps in the fleet's own prompt file and friends. Drop them from the
|
|
636
|
+
// index so a work commit carries work, not harness plumbing. Safe when absent.
|
|
637
|
+
unstageConductorArtifacts(root);
|
|
566
638
|
if (!proofTreeMatches('index')) return 3;
|
|
567
639
|
runGit(['commit', '-m', message], { cwd: root });
|
|
568
640
|
if (!proofTreeMatches('head')) return 3;
|
|
@@ -578,7 +650,12 @@ function shipWorktree(args) {
|
|
|
578
650
|
if (!dryRun) runCommand(verify, { cwd: root });
|
|
579
651
|
const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
|
|
580
652
|
const verifyDelta = newStatusRows(beforeVerify, afterVerifyRows);
|
|
581
|
-
|
|
653
|
+
// This check exists to catch a verifier that writes files. Conductor plumbing was
|
|
654
|
+
// already present before the verifier ran, so counting it here blocks a clean ship.
|
|
655
|
+
const afterVerify = dryRun
|
|
656
|
+
? { staged: 0, unstaged: 0, untracked: 0 }
|
|
657
|
+
: statusCounts(root, { ignoreUntracked: isConductorArtifact })
|
|
658
|
+
|| { staged: 0, unstaged: 0, untracked: 0 };
|
|
582
659
|
if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
|
|
583
660
|
console.error(
|
|
584
661
|
`blocked: verifier left checkout dirty staged=${afterVerify.staged} ` +
|
|
@@ -687,8 +764,10 @@ function guard(args) {
|
|
|
687
764
|
console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
|
|
688
765
|
return 2;
|
|
689
766
|
}
|
|
690
|
-
//
|
|
691
|
-
|
|
767
|
+
// Conductor plumbing (worktree metadata, the fleet prompt written for this very
|
|
768
|
+
// engine, runtime scratch, the brief ledger) must not count as dirt — otherwise the
|
|
769
|
+
// harness blocks the engine with the harness's own files.
|
|
770
|
+
const counts = statusCounts(root, { ignoreUntracked: isConductorArtifact });
|
|
692
771
|
if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
|
|
693
772
|
console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
|
|
694
773
|
return 3;
|
|
@@ -880,20 +959,14 @@ function worktreeCommand(args = []) {
|
|
|
880
959
|
|
|
881
960
|
module.exports = {
|
|
882
961
|
branchName,
|
|
883
|
-
statusSnapshot,
|
|
884
|
-
newStatusRows,
|
|
885
962
|
createAgentWorktree,
|
|
963
|
+
deniedLaneForShip,
|
|
886
964
|
createOrFindPr,
|
|
887
965
|
cleanupWorktrees,
|
|
888
|
-
defaultShipTarget,
|
|
889
966
|
defaultStartBase,
|
|
890
|
-
defaultWorktreePath,
|
|
891
967
|
listWorktrees,
|
|
892
968
|
parseWorktrees,
|
|
893
|
-
REGEN_ADAPTER_FILES,
|
|
894
969
|
normalizeTargetRef,
|
|
895
|
-
restoreRegeneratedAdapterChurn,
|
|
896
|
-
prMergeRef,
|
|
897
970
|
slugify,
|
|
898
971
|
statusCounts,
|
|
899
972
|
swarloClaim,
|
package/commands/xp.js
CHANGED
|
@@ -2353,6 +2353,7 @@ async function xpCommand(...args) {
|
|
|
2353
2353
|
});
|
|
2354
2354
|
if (!result.ok) {
|
|
2355
2355
|
console.error(`Failed to load XP graph: ${result.error || result.status}`);
|
|
2356
|
+
console.error('Run: atris xp --local to render your AgentXP from proof receipts in this workspace.');
|
|
2356
2357
|
process.exit(1);
|
|
2357
2358
|
}
|
|
2358
2359
|
|
|
@@ -2366,14 +2367,8 @@ async function xpCommand(...args) {
|
|
|
2366
2367
|
|
|
2367
2368
|
module.exports = {
|
|
2368
2369
|
xpCommand,
|
|
2369
|
-
buildContributionRows,
|
|
2370
2370
|
buildCareerXpProjection,
|
|
2371
|
-
buildAllCareerXpProjection,
|
|
2372
|
-
buildCareerXpSessionCapsule,
|
|
2373
|
-
collectAllLocalXpProjection,
|
|
2374
2371
|
collectLocalXpProjection,
|
|
2375
|
-
buildAgentXpSyncPacket,
|
|
2376
2372
|
syncAgentXp,
|
|
2377
|
-
receiptFromTaskEpisode,
|
|
2378
2373
|
render,
|
|
2379
2374
|
};
|
package/commands/youtube.js
CHANGED
|
@@ -452,13 +452,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
|
|
|
452
452
|
module.exports = {
|
|
453
453
|
DEFAULT_QUERY,
|
|
454
454
|
DEFAULT_TIMEOUT_MS,
|
|
455
|
-
showYoutubeHelp,
|
|
456
455
|
parseYoutubeArgs,
|
|
457
456
|
buildYoutubePayload,
|
|
458
457
|
extractLocalTranscript,
|
|
459
458
|
processYoutube,
|
|
460
459
|
shouldRetryWithLocalTranscript,
|
|
461
460
|
formatYoutubeResult,
|
|
462
|
-
formatTimestampFromMs,
|
|
463
461
|
youtubeCommand,
|
|
464
462
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The accept gate used to check that a task's verify field was non-empty, never
|
|
4
|
+
// that it held something that could fail. An audit on 2026-07-26 found 131 of 802
|
|
5
|
+
// accepted proofs (16.3%) were unfalsifiable at the moment they were signed off:
|
|
6
|
+
// 54 an unfilled template sentence, 55 a bare `git diff --check`, 21 a path into a
|
|
7
|
+
// deleted worktree, 1 a bare file path.
|
|
8
|
+
//
|
|
9
|
+
// This module answers one question before a task may be marked done: does the
|
|
10
|
+
// stored verify command actually run, and does it pass? Parsing and execution reuse
|
|
11
|
+
// lib/auto-accept-certified.js so there is one allow-list, not two.
|
|
12
|
+
|
|
13
|
+
const { parseVerifyCommand, runVerifyCommandCached } = require('./auto-accept-certified');
|
|
14
|
+
|
|
15
|
+
// Template text shipped in task scaffolding. Present verbatim in 54 accepted proofs.
|
|
16
|
+
const TEMPLATE_PLACEHOLDERS = [
|
|
17
|
+
'concrete command, file, receipt, or verifier evidence',
|
|
18
|
+
'command, file, receipt, or verifier evidence',
|
|
19
|
+
'<command>',
|
|
20
|
+
'tbd',
|
|
21
|
+
'n/a',
|
|
22
|
+
'none',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Commands that execute cleanly but cannot fail for the reason the task exists.
|
|
26
|
+
// `git diff --check` reports whitespace and conflict markers; it is green on a
|
|
27
|
+
// commit that deletes the feature. 55 accepted proofs stored exactly this.
|
|
28
|
+
const NON_FALSIFYING_COMMANDS = [
|
|
29
|
+
/^git\s+diff\s+--check\s*$/i,
|
|
30
|
+
/^git\s+status\s*$/i,
|
|
31
|
+
/^git\s+log\b/i,
|
|
32
|
+
/^ls\b/i,
|
|
33
|
+
/^true\s*$/i,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function storedVerifyCommand(task) {
|
|
37
|
+
const metadata = (task && task.metadata) || {};
|
|
38
|
+
const candidates = [metadata.verify, metadata.latest_agent_verify];
|
|
39
|
+
for (const candidate of candidates) {
|
|
40
|
+
const text = String(candidate || '').trim();
|
|
41
|
+
if (text) return text;
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function placeholderIssue(command) {
|
|
47
|
+
const lower = command.toLowerCase().trim();
|
|
48
|
+
if (TEMPLATE_PLACEHOLDERS.some((p) => lower === p || lower.startsWith(p))) {
|
|
49
|
+
return 'the verify field holds unfilled template text, not a command';
|
|
50
|
+
}
|
|
51
|
+
// A bare path is a receipt, not a check. `/tmp/x/proof.json` was one of the 131.
|
|
52
|
+
if (/^[./~]/.test(command) && !/\s/.test(command)) {
|
|
53
|
+
return 'the verify field holds a file path, not a command that can fail';
|
|
54
|
+
}
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nonFalsifyingIssue(command) {
|
|
59
|
+
if (NON_FALSIFYING_COMMANDS.some((re) => re.test(command.trim()))) {
|
|
60
|
+
return `\`${command.trim()}\` cannot fail for the reason this task exists — it passes on a commit that deletes the work`;
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns { ok, reason, detail, command, ran, exit_code }.
|
|
66
|
+
// ok:true means the stored command parsed, ran, and exited 0.
|
|
67
|
+
function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
|
|
68
|
+
const command = storedVerifyCommand(task);
|
|
69
|
+
// A task with no stored verify is out of scope here. Every one of the 131
|
|
70
|
+
// unfalsifiable proofs HAD a command; the defect is a stored check that cannot
|
|
71
|
+
// fail, not a missing one. Requiring a verify on every accept is a separate,
|
|
72
|
+
// larger policy change and blocking it here would only get this gate switched off.
|
|
73
|
+
if (!command) {
|
|
74
|
+
return { ok: true, reason: 'no_verify_command', detail: '', command: '', ran: false, unchecked: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const placeholder = placeholderIssue(command);
|
|
78
|
+
if (placeholder) return { ok: false, reason: 'verify_placeholder', detail: placeholder, command, ran: false };
|
|
79
|
+
|
|
80
|
+
const hollow = nonFalsifyingIssue(command);
|
|
81
|
+
if (hollow) return { ok: false, reason: 'verify_not_falsifying', detail: hollow, command, ran: false };
|
|
82
|
+
|
|
83
|
+
// Two different questions got fused here at first: "can this fail?" and "is this
|
|
84
|
+
// safe to execute at accept time?". Only the first is this gate's business.
|
|
85
|
+
// Refusing everything outside the execution allow-list flagged 455 accepted tasks,
|
|
86
|
+
// and sampling them found ordinary verifiers — `npm run type-check`, `npx vitest
|
|
87
|
+
// run` — sitting beside real prose. A gate that blocks real work gets switched off.
|
|
88
|
+
// So: refuse what provably cannot fail, run what can be run safely, and mark the
|
|
89
|
+
// rest unchecked rather than pretend a judgment we cannot make.
|
|
90
|
+
const parsed = parseVerifyCommand(command);
|
|
91
|
+
if (!parsed.ok) {
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
reason: 'verify_not_runnable_here',
|
|
95
|
+
detail: `the stored verify command cannot be safely executed at accept time (${parsed.reason || 'unknown'}); it was not checked`,
|
|
96
|
+
command,
|
|
97
|
+
ran: false,
|
|
98
|
+
unchecked: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = runVerifyCommandCached(command, workspaceRoot, cache);
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: result.reason || 'verify_failed',
|
|
107
|
+
detail: result.reason === 'verify_workdir_missing' || result.reason === 'verify_worktree_missing'
|
|
108
|
+
? 'the stored verify command points at a directory that no longer exists'
|
|
109
|
+
: `the stored verify command did not pass (${result.reason || 'nonzero exit'})`,
|
|
110
|
+
command,
|
|
111
|
+
ran: true,
|
|
112
|
+
exit_code: typeof result.status === 'number' ? result.status : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, reason: 'verify_passed', detail: '', command, ran: true, exit_code: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
storedVerifyCommand,
|
|
121
|
+
evaluateAcceptVerify,
|
|
122
|
+
};
|
package/lib/activity-stream.js
CHANGED
|
@@ -154,13 +154,11 @@ function buildHeartbeat(pulseReceipts, now = Date.now()) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
module.exports = {
|
|
157
|
-
pickTs,
|
|
158
157
|
clip,
|
|
159
158
|
normalizePulse,
|
|
160
159
|
normalizeScorecard,
|
|
161
160
|
normalizeTaskEpisode,
|
|
162
161
|
normalizeXp,
|
|
163
|
-
normalizeMissionEvent,
|
|
164
162
|
buildActivityStream,
|
|
165
163
|
buildHeartbeat,
|
|
166
164
|
};
|
|
@@ -458,11 +458,29 @@ function runVerifyCommand(verify, workspaceRoot) {
|
|
|
458
458
|
if (!pythonPathCheck.ok) return pythonPathCheck;
|
|
459
459
|
const result = spawnSync(parsed.argv[0], parsed.argv.slice(1), {
|
|
460
460
|
cwd: cwdCheck.cwd,
|
|
461
|
-
env: parsed.env
|
|
461
|
+
env: verifyCommandEnv(parsed.env),
|
|
462
462
|
shell: false,
|
|
463
463
|
encoding: 'utf8',
|
|
464
464
|
timeout: 120000,
|
|
465
465
|
});
|
|
466
|
+
// "I could not run the check" is not "the check failed". Collapsing the two
|
|
467
|
+
// let a broken harness read as broken code: under cron's PATH every
|
|
468
|
+
// `npm run ...` verifier returned ENOENT, mapped to verify_failed, and 16
|
|
469
|
+
// landable tasks were skipped silently every hour for 20 days. A verifier
|
|
470
|
+
// the harness cannot execute must be loud and must never look like a
|
|
471
|
+
// verdict on the work.
|
|
472
|
+
const unrunnable = unrunnableVerifyReason(result);
|
|
473
|
+
if (unrunnable) {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
reason: 'verify_unrunnable',
|
|
477
|
+
unrunnable_cause: unrunnable,
|
|
478
|
+
alarm: true,
|
|
479
|
+
command: parsed.argv.join(' '),
|
|
480
|
+
status: result.status,
|
|
481
|
+
stderr: String(result.stderr || '').slice(0, 400),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
466
484
|
return {
|
|
467
485
|
ok: result.status === 0,
|
|
468
486
|
reason: result.status === 0 ? 'verify_passed' : 'verify_failed',
|
|
@@ -471,6 +489,36 @@ function runVerifyCommand(verify, workspaceRoot) {
|
|
|
471
489
|
};
|
|
472
490
|
}
|
|
473
491
|
|
|
492
|
+
// Verifiers run with shell:false, so the child gets no PATH resolution beyond
|
|
493
|
+
// whatever the parent inherited. Cron has no Homebrew PATH — that is why every
|
|
494
|
+
// cron entry hardcodes an absolute node path. `npm`, `npx` and friends live in
|
|
495
|
+
// the same bin directory as the node binary currently executing, so seeding
|
|
496
|
+
// PATH with it makes recorded checks runnable from cron with zero config: no
|
|
497
|
+
// crontab PATH line to keep in sync, no new env var.
|
|
498
|
+
function verifyCommandEnv(extraEnv) {
|
|
499
|
+
const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
|
|
500
|
+
const binDir = path.dirname(process.execPath);
|
|
501
|
+
const current = String(base.PATH || '');
|
|
502
|
+
const alreadyPresent = current.split(path.delimiter).includes(binDir);
|
|
503
|
+
if (binDir && !alreadyPresent) {
|
|
504
|
+
base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
|
|
505
|
+
}
|
|
506
|
+
return base;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ENOENT: the binary is not on PATH at all. ETIMEDOUT: the 120s ceiling cut it
|
|
510
|
+
// off before it could reach a verdict. Neither is evidence about the diff.
|
|
511
|
+
function unrunnableVerifyReason(result) {
|
|
512
|
+
const code = result && result.error ? result.error.code : null;
|
|
513
|
+
if (code === 'ENOENT') return 'command_not_found';
|
|
514
|
+
if (code === 'ETIMEDOUT' || result.signal === 'SIGTERM') return 'verify_timed_out';
|
|
515
|
+
if (code) return String(code).toLowerCase();
|
|
516
|
+
// spawnSync reports a null status with no error object when the child was
|
|
517
|
+
// killed outright; treat that as unrunnable rather than a silent failure.
|
|
518
|
+
if (result && result.status === null) return 'no_exit_status';
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
474
522
|
function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
|
|
475
523
|
if (!(cache instanceof Map)) return runVerifyCommand(verify, workspaceRoot);
|
|
476
524
|
const key = `${path.resolve(workspaceRoot || process.cwd())}\u0000${String(verify || '').trim()}`;
|
|
@@ -507,6 +555,61 @@ function receiptEvidenceBlock(task, ref) {
|
|
|
507
555
|
return null;
|
|
508
556
|
}
|
|
509
557
|
|
|
558
|
+
// Authors declare protected lanes in the task text, not the tag field. Match
|
|
559
|
+
// only explicit, deliberate declarations — an unanchored "security" would
|
|
560
|
+
// snag every task that merely mentions the word and wedge the loop shut,
|
|
561
|
+
// which is its own failure mode. Kept narrow on purpose.
|
|
562
|
+
const PROTECTED_LANE_PHRASES = [
|
|
563
|
+
'protected lane',
|
|
564
|
+
'never self-land',
|
|
565
|
+
'never self land',
|
|
566
|
+
'do not self-land',
|
|
567
|
+
'human accepts',
|
|
568
|
+
'human must accept',
|
|
569
|
+
'human review required',
|
|
570
|
+
'orchestrator reviews pre-land',
|
|
571
|
+
'reviews pre-land',
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
function declaredProtectedLane(task) {
|
|
575
|
+
const haystack = [
|
|
576
|
+
task.title,
|
|
577
|
+
task.objective,
|
|
578
|
+
task.metadata && task.metadata.protected_lane_note,
|
|
579
|
+
]
|
|
580
|
+
.map((v) => String(v || ''))
|
|
581
|
+
.join('\n')
|
|
582
|
+
.toLowerCase();
|
|
583
|
+
if (!haystack.trim()) return null;
|
|
584
|
+
const phrase = PROTECTED_LANE_PHRASES.find((p) => haystack.includes(p));
|
|
585
|
+
return phrase ? { phrase } : null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Only consulted when a task carries no tag at all, so this never overrides an
|
|
589
|
+
// author who did classify their work. Terms are concrete nouns from the denied
|
|
590
|
+
// lanes — things you can only be touching on purpose. Deliberately excludes
|
|
591
|
+
// broad words like "auth", "user", or "api" that appear in ordinary frontend
|
|
592
|
+
// work and would hold the whole queue.
|
|
593
|
+
const PROTECTED_LANE_TERMS = [
|
|
594
|
+
['billing', ['stripe', 'checkout url', 'checkout_url', 'invoice', 'paywall', 'subscription', 'wallet', 'credit card', 'pricing tier']],
|
|
595
|
+
['security', ['session minting', 'mints a', 'service token', 'api key', 'credential', 'secret key', 'csrf', 'access token']],
|
|
596
|
+
['deploy', ['render deploy', 'production deploy', 'deploy to prod', 'migration apply']],
|
|
597
|
+
['customer', ['send email to', 'customer email', 'outbound email', 'notify users']],
|
|
598
|
+
];
|
|
599
|
+
|
|
600
|
+
function sniffedProtectedLane(task) {
|
|
601
|
+
const haystack = [task.title, task.objective]
|
|
602
|
+
.map((v) => String(v || ''))
|
|
603
|
+
.join('\n')
|
|
604
|
+
.toLowerCase();
|
|
605
|
+
if (!haystack.trim()) return null;
|
|
606
|
+
for (const [lane, terms] of PROTECTED_LANE_TERMS) {
|
|
607
|
+
const term = terms.find((t) => haystack.includes(t));
|
|
608
|
+
if (term) return { lane, term };
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
510
613
|
function evaluateAutoAccept(task, options = {}) {
|
|
511
614
|
const {
|
|
512
615
|
strictVerify = true,
|
|
@@ -539,12 +642,47 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
539
642
|
tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
|
|
540
643
|
if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
|
|
541
644
|
|
|
645
|
+
// A protected lane declared in prose is not a protection, it is a note.
|
|
646
|
+
// WEB-410 said "PROTECTED LANE: auth/session minting ... never self-land"
|
|
647
|
+
// in its own title, carried tag 'endgame', and auto-landed anyway. Routing
|
|
648
|
+
// safety through a tag field that authors leave empty fails open, silently.
|
|
649
|
+
// So read the declaration where authors actually write it.
|
|
650
|
+
const declared = declaredProtectedLane(task);
|
|
651
|
+
if (declared) {
|
|
652
|
+
return {
|
|
653
|
+
eligible: false,
|
|
654
|
+
ref,
|
|
655
|
+
reason: 'declared_protected_lane',
|
|
656
|
+
declared_lane: declared.phrase,
|
|
657
|
+
next_action: 'this task declares a protected lane in its own text; a human accepts it with `atris task accept <ref> --as <human>`',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
542
661
|
// accept-all: the protected lanes above are the only human gate. No
|
|
543
662
|
// certification, pass-count, reviewer, or proof-quality bar — but work
|
|
544
663
|
// is never marked done against evidence it isn't: a proof naming an
|
|
545
664
|
// unmerged draft PR still blocks, and a recorded check that FAILS still
|
|
546
665
|
// blocks (absence of a check does not).
|
|
547
666
|
if (acceptAll) {
|
|
667
|
+
// In this lane the tag IS the safety system — the only human gate. But
|
|
668
|
+
// most rows carry no tag at all (13 of 16 in the 2026-07-25 sweep), so
|
|
669
|
+
// refusing every untagged task would wedge the loop shut, which is the
|
|
670
|
+
// same outage as failing open, just quieter. Instead: when there is no
|
|
671
|
+
// tag to route on, read the task text for the denied lanes themselves.
|
|
672
|
+
// WEB-406 (Stripe checkout + paywall) landed untagged this way.
|
|
673
|
+
if (!tag) {
|
|
674
|
+
const sniffed = sniffedProtectedLane(task);
|
|
675
|
+
if (sniffed) {
|
|
676
|
+
return {
|
|
677
|
+
eligible: false,
|
|
678
|
+
ref,
|
|
679
|
+
reason: 'untagged_protected_lane_text',
|
|
680
|
+
declared_lane: sniffed.lane,
|
|
681
|
+
matched: sniffed.term,
|
|
682
|
+
next_action: `no tag to route on and the text reads as the ${sniffed.lane} lane; tag it or accept explicitly as a human`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
}
|
|
548
686
|
const proof = latestProof(task);
|
|
549
687
|
if (proofHasUnmergedPullRequestBoundary(proof)) {
|
|
550
688
|
return unmergedPullRequestBoundaryResult(ref, proof);
|
|
@@ -570,8 +708,12 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
570
708
|
// into "lands unchecked" the morning after it clears the worktree.
|
|
571
709
|
// A check that merely isn't in the runnable allowlist counts as no
|
|
572
710
|
// check at all.
|
|
711
|
+
// verify_unrunnable blocks for the same reason worktree_missing does:
|
|
712
|
+
// it used to arrive here as verify_failed, so leaving it out would turn
|
|
713
|
+
// "the harness could not run the check" into "landed unchecked".
|
|
573
714
|
if ((tierRequiresStrictVerify && !verifyResult.ok)
|
|
574
715
|
|| verifyResult.reason === 'verify_failed'
|
|
716
|
+
|| verifyResult.reason === 'verify_unrunnable'
|
|
575
717
|
|| verifyResult.reason === 'verify_worktree_missing') {
|
|
576
718
|
return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
|
|
577
719
|
}
|
|
@@ -663,8 +805,9 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
663
805
|
|
|
664
806
|
module.exports = {
|
|
665
807
|
AGENT_CERTIFICATION_REVIEW_PASSES,
|
|
666
|
-
AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES,
|
|
667
808
|
DENIED_TAGS,
|
|
809
|
+
declaredProtectedLane,
|
|
810
|
+
sniffedProtectedLane,
|
|
668
811
|
evaluateAutoAccept,
|
|
669
812
|
isAutoCertifyVerifyCommandAllowed,
|
|
670
813
|
isAgentCertified,
|
package/lib/autoland.js
CHANGED
|
@@ -661,7 +661,6 @@ module.exports = {
|
|
|
661
661
|
composeDigest,
|
|
662
662
|
composeLiveUpdate,
|
|
663
663
|
cronInstalled,
|
|
664
|
-
cronMarker,
|
|
665
664
|
dueForAlarm,
|
|
666
665
|
digestLine,
|
|
667
666
|
dejargon,
|
|
@@ -669,7 +668,6 @@ module.exports = {
|
|
|
669
668
|
installCron,
|
|
670
669
|
liveAcceptAuthorization,
|
|
671
670
|
markAlerted,
|
|
672
|
-
missionDigestLine,
|
|
673
671
|
operatorReady,
|
|
674
672
|
hasAgentJargon,
|
|
675
673
|
voicePatterns: Object.freeze({
|
|
@@ -685,7 +683,6 @@ module.exports = {
|
|
|
685
683
|
sendImessage,
|
|
686
684
|
statePath,
|
|
687
685
|
uninstallCron,
|
|
688
|
-
waitingHours,
|
|
689
686
|
waitingOnHuman,
|
|
690
687
|
writePolicy,
|
|
691
688
|
writeState,
|