atris 3.37.1 → 3.40.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/AGENTS.md +25 -6
- package/README.md +2 -0
- package/atris/PERSONA.md +11 -11
- package/atris.md +17 -2
- package/ax +160 -34
- package/bin/atris.js +78 -6
- package/commands/activate.js +16 -3
- package/commands/agent-spawn.js +13 -18
- package/commands/autoland.js +76 -47
- package/commands/autopilot-front.js +0 -4
- package/commands/autopilot.js +0 -12
- package/commands/bench.js +10 -12
- package/commands/brain.js +0 -7
- package/commands/brief.js +0 -2
- package/commands/business-sync.js +0 -1
- package/commands/business.js +452 -12
- package/commands/chat-scan.js +5 -7
- package/commands/clean.js +31 -2
- package/commands/close.js +46 -11
- package/commands/cloud.js +0 -1
- package/commands/codex-goal.js +8 -12
- package/commands/computer.js +6 -5
- package/commands/console.js +64 -11
- package/commands/decide.js +166 -0
- package/commands/deck.js +30 -12
- package/commands/dream.js +0 -7
- package/commands/drill.js +14 -25
- package/commands/engine.js +206 -26
- package/commands/experiments.js +0 -4
- package/commands/fleet-report.js +12 -4
- package/commands/game.js +261 -0
- package/commands/gm.js +17 -7
- package/commands/goal.js +0 -6
- package/commands/harvest.js +1 -5
- package/commands/improve.js +29 -8
- package/commands/init.js +41 -37
- package/commands/integrations.js +0 -7
- package/commands/land.js +77 -20
- package/commands/launchpad.js +1 -16
- package/commands/lesson.js +17 -7
- package/commands/lifecycle.js +5 -5
- package/commands/log.js +55 -5
- package/commands/loop-front.js +0 -2
- package/commands/loops.js +6 -5
- package/commands/meet.js +0 -2
- package/commands/member.js +622 -573
- package/commands/mission.js +790 -314
- 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 +3599 -424
- package/commands/play.js +6 -6
- package/commands/probe.js +12 -6
- package/commands/pulse.js +88 -64
- package/commands/push.js +0 -4
- package/commands/radar.js +14 -11
- package/commands/recover.js +340 -0
- package/commands/release.js +10 -9
- package/commands/report.js +0 -1
- package/commands/router.js +320 -0
- package/commands/run-front.js +0 -2
- package/commands/search.js +0 -3
- package/commands/site-deploy.js +870 -0
- package/commands/site.js +11 -2
- package/commands/skill.js +7 -2
- package/commands/slop.js +237 -7
- package/commands/status.js +6 -0
- package/commands/stream.js +4 -20
- package/commands/study.js +0 -3
- package/commands/sync-checkout.js +51 -0
- package/commands/sync.js +11 -40
- package/commands/task.js +999 -595
- package/commands/taste.js +101 -0
- package/commands/teach.js +86 -0
- package/commands/team.js +83 -3
- package/commands/unknowns.js +0 -10
- package/commands/update.js +0 -1
- package/commands/vercel.js +4 -2
- package/commands/visualize.js +0 -5
- package/commands/voice.js +195 -0
- package/commands/watch.js +1 -28
- package/commands/wiki.js +42 -15
- package/commands/wish.js +0 -1
- package/commands/workflow.js +2 -2
- package/commands/worktree.js +88 -28
- package/commands/xp.js +28 -30
- package/commands/youtube.js +0 -2
- package/lib/accept-verify-gate.js +126 -0
- package/lib/activity-stream.js +0 -2
- package/lib/arg-parser.js +41 -0
- package/lib/auto-accept-certified.js +261 -3
- package/lib/autoland.js +66 -3
- package/lib/ax-auto-lane.js +68 -7
- package/lib/bench/context.js +0 -2
- package/lib/bench/report.js +0 -1
- package/lib/bench/runner.js +18 -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 +7 -2
- 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 +157 -28
- package/lib/experiments/daily.js +0 -6
- package/lib/falsifier-probe.js +84 -0
- package/lib/fleet.js +275 -42
- package/lib/functional-owner.js +0 -3
- package/lib/git-spawn.js +15 -0
- package/lib/inspect-fields.js +0 -1
- package/lib/json-file.js +37 -0
- package/lib/known-commands.js +2 -2
- package/lib/learnings.js +0 -2
- package/lib/lesson-preflight.js +146 -0
- package/lib/loop-doctor.js +11 -19
- 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-human-asks.js +28 -0
- package/lib/mission-protected-lane.js +296 -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 +47 -6
- package/lib/orb-context.js +14 -4
- package/lib/orb-scorecard.js +0 -1
- package/lib/outbound-send-gate.js +0 -6
- package/lib/pack-capabilities.js +685 -0
- 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 +51 -3
- package/lib/runner-command.js +0 -10
- package/lib/runtime-bootstrap.js +0 -1
- package/lib/scorecard.js +0 -1
- package/lib/self-drive.js +45 -14
- package/lib/state-detection.js +0 -6
- package/lib/task-db.js +137 -7
- package/lib/task-decision.js +50 -0
- package/lib/taste-lessons.js +153 -0
- package/lib/team-presence.js +0 -1
- package/lib/tool-result-encode.js +17 -1
- package/lib/voice-gate.js +129 -2
- package/lib/wiki.js +152 -9
- package/lib/wish-audit.js +21 -7
- package/lib/wish-delegate.js +27 -21
- 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 +95 -8
- package/package.json +2 -1
- package/templates/business-starter/persona.md +9 -0
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
const diffCheck = /^git\s+diff\s+--check\b/i.test(command.trim());
|
|
105
|
+
const fixHint = diffCheck
|
|
106
|
+
? '; trailing whitespace in markdown is auto-fixable: npm run audit:markdown-whitespace -- --fix'
|
|
107
|
+
: '';
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: result.reason || 'verify_failed',
|
|
111
|
+
detail: result.reason === 'verify_workdir_missing' || result.reason === 'verify_worktree_missing'
|
|
112
|
+
? 'the stored verify command points at a directory that no longer exists'
|
|
113
|
+
: `the stored verify command did not pass (${result.reason || 'nonzero exit'})${fixHint}`,
|
|
114
|
+
command,
|
|
115
|
+
ran: true,
|
|
116
|
+
exit_code: typeof result.status === 'number' ? result.status : null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ok: true, reason: 'verify_passed', detail: '', command, ran: true, exit_code: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
storedVerifyCommand,
|
|
125
|
+
evaluateAcceptVerify,
|
|
126
|
+
};
|
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
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function hasFlag(args, name) {
|
|
4
|
+
return args.includes(name);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function unquote(value) {
|
|
8
|
+
const text = String(value);
|
|
9
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
10
|
+
return text.slice(1, -1);
|
|
11
|
+
}
|
|
12
|
+
return text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readFlag(args, name, fallback = '') {
|
|
16
|
+
const prefix = `${name}=`;
|
|
17
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
18
|
+
const arg = String(args[i]);
|
|
19
|
+
if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
|
|
20
|
+
return unquote(args[i + 1]);
|
|
21
|
+
}
|
|
22
|
+
if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
|
|
23
|
+
}
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readIntFlag(args, name, fallback = null) {
|
|
28
|
+
const raw = readFlag(args, name, '');
|
|
29
|
+
if (!raw) return fallback;
|
|
30
|
+
const value = Number.parseInt(raw, 10);
|
|
31
|
+
return Number.isFinite(value) ? value : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readNumberFlag(args, name, fallback = null) {
|
|
35
|
+
const raw = readFlag(args, name, '');
|
|
36
|
+
if (!raw) return fallback;
|
|
37
|
+
const value = Number(raw);
|
|
38
|
+
return Number.isFinite(value) ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { hasFlag, readFlag, readIntFlag, readNumberFlag };
|
|
@@ -13,7 +13,7 @@ const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
|
|
|
13
13
|
// once powered is gone. Passes alone never land work, an independent
|
|
14
14
|
// reviewer does.
|
|
15
15
|
const AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES = 3;
|
|
16
|
-
const DENIED_TAGS = new Set(['billing', 'deploy', 'feedback', 'security', 'customer', 'external']);
|
|
16
|
+
const DENIED_TAGS = new Set(['billing', 'money', 'payments', 'deploy', 'feedback', 'security', 'customer', 'external']);
|
|
17
17
|
|
|
18
18
|
const SIMPLE_VERIFY_TOKEN_RE = /^[a-zA-Z0-9_./:@=+-]+$/;
|
|
19
19
|
const GIT_WORKTREE_PATH_RE = /^[a-zA-Z0-9_./@=+-]+$/;
|
|
@@ -447,7 +447,41 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
|
|
|
447
447
|
return parseVerifyCommand(verify).ok;
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
// Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
|
|
451
|
+
// (see verifyCommandEnv). A verify that shells back into the CLI and reaches
|
|
452
|
+
// another verify would otherwise recurse without bound — that cycle
|
|
453
|
+
// fork-bombed the fleet on 2026-07-29 when a stored pytest called
|
|
454
|
+
// `atris task status` and the read path re-ran the verify. Refuse loudly.
|
|
455
|
+
const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
|
|
456
|
+
|
|
457
|
+
// Spawn cap: a single CLI process has no legitimate reason to run dozens of
|
|
458
|
+
// verifies. Beyond the cap something is looping; refuse and report instead
|
|
459
|
+
// of silently bounding.
|
|
460
|
+
const VERIFY_SPAWN_CAP = 25;
|
|
461
|
+
let verifySpawnCount = 0;
|
|
462
|
+
|
|
463
|
+
function verifyRefusal(unrunnableCause, extra) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
reason: 'verify_unrunnable',
|
|
467
|
+
unrunnable_cause: unrunnableCause,
|
|
468
|
+
alarm: true,
|
|
469
|
+
...(extra || {}),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
450
473
|
function runVerifyCommand(verify, workspaceRoot) {
|
|
474
|
+
if (process.env[VERIFY_IN_PROGRESS_ENV]) {
|
|
475
|
+
return verifyRefusal('verify_reentrant', {
|
|
476
|
+
detail: `${VERIFY_IN_PROGRESS_ENV} is set: a verify is already running up the call chain; refusing to nest another one`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (verifySpawnCount >= VERIFY_SPAWN_CAP) {
|
|
480
|
+
return verifyRefusal('verify_spawn_cap', {
|
|
481
|
+
detail: `this process already ran ${verifySpawnCount} verifies (cap ${VERIFY_SPAWN_CAP}); refusing more`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
verifySpawnCount += 1;
|
|
451
485
|
const parsed = parseVerifyCommand(verify);
|
|
452
486
|
if (!parsed.ok) return parsed;
|
|
453
487
|
const cwdCheck = validateCommandCwd(parsed, workspaceRoot);
|
|
@@ -458,11 +492,29 @@ function runVerifyCommand(verify, workspaceRoot) {
|
|
|
458
492
|
if (!pythonPathCheck.ok) return pythonPathCheck;
|
|
459
493
|
const result = spawnSync(parsed.argv[0], parsed.argv.slice(1), {
|
|
460
494
|
cwd: cwdCheck.cwd,
|
|
461
|
-
env: parsed.env
|
|
495
|
+
env: verifyCommandEnv(parsed.env),
|
|
462
496
|
shell: false,
|
|
463
497
|
encoding: 'utf8',
|
|
464
498
|
timeout: 120000,
|
|
465
499
|
});
|
|
500
|
+
// "I could not run the check" is not "the check failed". Collapsing the two
|
|
501
|
+
// let a broken harness read as broken code: under cron's PATH every
|
|
502
|
+
// `npm run ...` verifier returned ENOENT, mapped to verify_failed, and 16
|
|
503
|
+
// landable tasks were skipped silently every hour for 20 days. A verifier
|
|
504
|
+
// the harness cannot execute must be loud and must never look like a
|
|
505
|
+
// verdict on the work.
|
|
506
|
+
const unrunnable = unrunnableVerifyReason(result);
|
|
507
|
+
if (unrunnable) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
reason: 'verify_unrunnable',
|
|
511
|
+
unrunnable_cause: unrunnable,
|
|
512
|
+
alarm: true,
|
|
513
|
+
command: parsed.argv.join(' '),
|
|
514
|
+
status: result.status,
|
|
515
|
+
stderr: String(result.stderr || '').slice(0, 400),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
466
518
|
return {
|
|
467
519
|
ok: result.status === 0,
|
|
468
520
|
reason: result.status === 0 ? 'verify_passed' : 'verify_failed',
|
|
@@ -471,6 +523,37 @@ function runVerifyCommand(verify, workspaceRoot) {
|
|
|
471
523
|
};
|
|
472
524
|
}
|
|
473
525
|
|
|
526
|
+
// Verifiers run with shell:false, so the child gets no PATH resolution beyond
|
|
527
|
+
// whatever the parent inherited. Cron has no Homebrew PATH — that is why every
|
|
528
|
+
// cron entry hardcodes an absolute node path. `npm`, `npx` and friends live in
|
|
529
|
+
// the same bin directory as the node binary currently executing, so seeding
|
|
530
|
+
// PATH with it makes recorded checks runnable from cron with zero config: no
|
|
531
|
+
// crontab PATH line to keep in sync, no new env var.
|
|
532
|
+
function verifyCommandEnv(extraEnv) {
|
|
533
|
+
const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
|
|
534
|
+
const binDir = path.dirname(process.execPath);
|
|
535
|
+
const current = String(base.PATH || '');
|
|
536
|
+
const alreadyPresent = current.split(path.delimiter).includes(binDir);
|
|
537
|
+
if (binDir && !alreadyPresent) {
|
|
538
|
+
base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
|
|
539
|
+
}
|
|
540
|
+
base[VERIFY_IN_PROGRESS_ENV] = '1';
|
|
541
|
+
return base;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ENOENT: the binary is not on PATH at all. ETIMEDOUT: the 120s ceiling cut it
|
|
545
|
+
// off before it could reach a verdict. Neither is evidence about the diff.
|
|
546
|
+
function unrunnableVerifyReason(result) {
|
|
547
|
+
const code = result && result.error ? result.error.code : null;
|
|
548
|
+
if (code === 'ENOENT') return 'command_not_found';
|
|
549
|
+
if (code === 'ETIMEDOUT' || result.signal === 'SIGTERM') return 'verify_timed_out';
|
|
550
|
+
if (code) return String(code).toLowerCase();
|
|
551
|
+
// spawnSync reports a null status with no error object when the child was
|
|
552
|
+
// killed outright; treat that as unrunnable rather than a silent failure.
|
|
553
|
+
if (result && result.status === null) return 'no_exit_status';
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
474
557
|
function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
|
|
475
558
|
if (!(cache instanceof Map)) return runVerifyCommand(verify, workspaceRoot);
|
|
476
559
|
const key = `${path.resolve(workspaceRoot || process.cwd())}\u0000${String(verify || '').trim()}`;
|
|
@@ -480,6 +563,63 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
|
|
|
480
563
|
return { ...result, reused: false };
|
|
481
564
|
}
|
|
482
565
|
|
|
566
|
+
// Pre-land hygiene: dead exports used to surface only AFTER landing, when the
|
|
567
|
+
// full suite's repo-hygiene ratchet went red on master (lesson:
|
|
568
|
+
// engine-dead-exports — engines export every internal helper, the task's own
|
|
569
|
+
// verify command stays green, and the breakage lands). In repos that carry the
|
|
570
|
+
// ratchet (test/repo-hygiene.test.js), run the same detector before landing so
|
|
571
|
+
// the gate refuses the work instead of master discovering it. Memoized per
|
|
572
|
+
// root: one scan covers every task in a sweep.
|
|
573
|
+
const repoHygieneCache = new Map();
|
|
574
|
+
|
|
575
|
+
function repoHygieneGate(workspaceRoot) {
|
|
576
|
+
const root = path.resolve(workspaceRoot || process.cwd());
|
|
577
|
+
if (repoHygieneCache.has(root)) return repoHygieneCache.get(root);
|
|
578
|
+
let result = { ok: true, skipped: true };
|
|
579
|
+
if (fs.existsSync(path.join(root, 'test', 'repo-hygiene.test.js'))) {
|
|
580
|
+
try {
|
|
581
|
+
const { findDeadCode, findOrphanedExports, listJsFiles } = require('../commands/slop');
|
|
582
|
+
const dead = findDeadCode(root).dead;
|
|
583
|
+
const files = ['commands', 'lib'].flatMap((d) => listJsFiles(path.join(root, d)));
|
|
584
|
+
const orphans = findOrphanedExports(root, files, listJsFiles(root));
|
|
585
|
+
const offenders = [
|
|
586
|
+
...dead.map((f) => path.relative(root, f)),
|
|
587
|
+
...orphans.map((o) => `${path.relative(root, o.file)} → ${o.name}`),
|
|
588
|
+
];
|
|
589
|
+
result = offenders.length
|
|
590
|
+
? {
|
|
591
|
+
ok: false,
|
|
592
|
+
reason: 'dead_exports',
|
|
593
|
+
offenders: offenders.slice(0, 12),
|
|
594
|
+
message: 'this work leaves code or exports nothing uses, so the full test suite fails right after landing; delete the unused pieces and land again.',
|
|
595
|
+
}
|
|
596
|
+
: { ok: true };
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// "I could not run the detector" is not a verdict on the work — same
|
|
599
|
+
// rule as verify_unrunnable, but hygiene is a repo-wide ratchet the
|
|
600
|
+
// suite still enforces, so failing open here only delays the red.
|
|
601
|
+
result = { ok: true, skipped: true, error: String((err && err.message) || err).slice(0, 200) };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
repoHygieneCache.set(root, result);
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Shared by both landing lanes so the check cannot ship in one dispatch branch
|
|
609
|
+
// and silently skip the sibling (lesson: parallel-paths-drift).
|
|
610
|
+
function hygieneBlockResult(task, ref) {
|
|
611
|
+
const hygiene = repoHygieneGate(task.workspace_root || process.cwd());
|
|
612
|
+
if (hygiene.ok) return null;
|
|
613
|
+
return {
|
|
614
|
+
eligible: false,
|
|
615
|
+
ref,
|
|
616
|
+
reason: hygiene.reason,
|
|
617
|
+
message: hygiene.message,
|
|
618
|
+
offenders: hygiene.offenders,
|
|
619
|
+
next_action: 'run `node --test test/repo-hygiene.test.js` in the workspace, delete what it names, then re-certify',
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
483
623
|
function strictVerifyMissingResult(ref) {
|
|
484
624
|
return {
|
|
485
625
|
eligible: false,
|
|
@@ -507,6 +647,61 @@ function receiptEvidenceBlock(task, ref) {
|
|
|
507
647
|
return null;
|
|
508
648
|
}
|
|
509
649
|
|
|
650
|
+
// Authors declare protected lanes in the task text, not the tag field. Match
|
|
651
|
+
// only explicit, deliberate declarations — an unanchored "security" would
|
|
652
|
+
// snag every task that merely mentions the word and wedge the loop shut,
|
|
653
|
+
// which is its own failure mode. Kept narrow on purpose.
|
|
654
|
+
const PROTECTED_LANE_PHRASES = [
|
|
655
|
+
'protected lane',
|
|
656
|
+
'never self-land',
|
|
657
|
+
'never self land',
|
|
658
|
+
'do not self-land',
|
|
659
|
+
'human accepts',
|
|
660
|
+
'human must accept',
|
|
661
|
+
'human review required',
|
|
662
|
+
'orchestrator reviews pre-land',
|
|
663
|
+
'reviews pre-land',
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
function declaredProtectedLane(task) {
|
|
667
|
+
const haystack = [
|
|
668
|
+
task.title,
|
|
669
|
+
task.objective,
|
|
670
|
+
task.metadata && task.metadata.protected_lane_note,
|
|
671
|
+
]
|
|
672
|
+
.map((v) => String(v || ''))
|
|
673
|
+
.join('\n')
|
|
674
|
+
.toLowerCase();
|
|
675
|
+
if (!haystack.trim()) return null;
|
|
676
|
+
const phrase = PROTECTED_LANE_PHRASES.find((p) => haystack.includes(p));
|
|
677
|
+
return phrase ? { phrase } : null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Only consulted when a task carries no tag at all, so this never overrides an
|
|
681
|
+
// author who did classify their work. Terms are concrete nouns from the denied
|
|
682
|
+
// lanes — things you can only be touching on purpose. Deliberately excludes
|
|
683
|
+
// broad words like "auth", "user", or "api" that appear in ordinary frontend
|
|
684
|
+
// work and would hold the whole queue.
|
|
685
|
+
const PROTECTED_LANE_TERMS = [
|
|
686
|
+
['billing', ['stripe', 'checkout url', 'checkout_url', 'invoice', 'paywall', 'subscription', 'wallet', 'credit card', 'pricing tier']],
|
|
687
|
+
['security', ['session minting', 'mints a', 'service token', 'api key', 'credential', 'secret key', 'csrf', 'access token']],
|
|
688
|
+
['deploy', ['render deploy', 'production deploy', 'deploy to prod', 'migration apply']],
|
|
689
|
+
['customer', ['send email to', 'customer email', 'outbound email', 'notify users']],
|
|
690
|
+
];
|
|
691
|
+
|
|
692
|
+
function sniffedProtectedLane(task) {
|
|
693
|
+
const haystack = [task.title, task.objective]
|
|
694
|
+
.map((v) => String(v || ''))
|
|
695
|
+
.join('\n')
|
|
696
|
+
.toLowerCase();
|
|
697
|
+
if (!haystack.trim()) return null;
|
|
698
|
+
for (const [lane, terms] of PROTECTED_LANE_TERMS) {
|
|
699
|
+
const term = terms.find((t) => haystack.includes(t));
|
|
700
|
+
if (term) return { lane, term };
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
510
705
|
function evaluateAutoAccept(task, options = {}) {
|
|
511
706
|
const {
|
|
512
707
|
strictVerify = true,
|
|
@@ -539,12 +734,47 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
539
734
|
tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
|
|
540
735
|
if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
|
|
541
736
|
|
|
737
|
+
// A protected lane declared in prose is not a protection, it is a note.
|
|
738
|
+
// WEB-410 said "PROTECTED LANE: auth/session minting ... never self-land"
|
|
739
|
+
// in its own title, carried tag 'endgame', and auto-landed anyway. Routing
|
|
740
|
+
// safety through a tag field that authors leave empty fails open, silently.
|
|
741
|
+
// So read the declaration where authors actually write it.
|
|
742
|
+
const declared = declaredProtectedLane(task);
|
|
743
|
+
if (declared) {
|
|
744
|
+
return {
|
|
745
|
+
eligible: false,
|
|
746
|
+
ref,
|
|
747
|
+
reason: 'declared_protected_lane',
|
|
748
|
+
declared_lane: declared.phrase,
|
|
749
|
+
next_action: 'this task declares a protected lane in its own text; a human accepts it with `atris task accept <ref> --as <human>`',
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
542
753
|
// accept-all: the protected lanes above are the only human gate. No
|
|
543
754
|
// certification, pass-count, reviewer, or proof-quality bar — but work
|
|
544
755
|
// is never marked done against evidence it isn't: a proof naming an
|
|
545
756
|
// unmerged draft PR still blocks, and a recorded check that FAILS still
|
|
546
757
|
// blocks (absence of a check does not).
|
|
547
758
|
if (acceptAll) {
|
|
759
|
+
// In this lane the tag IS the safety system — the only human gate. But
|
|
760
|
+
// most rows carry no tag at all (13 of 16 in the 2026-07-25 sweep), so
|
|
761
|
+
// refusing every untagged task would wedge the loop shut, which is the
|
|
762
|
+
// same outage as failing open, just quieter. Instead: when there is no
|
|
763
|
+
// tag to route on, read the task text for the denied lanes themselves.
|
|
764
|
+
// WEB-406 (Stripe checkout + paywall) landed untagged this way.
|
|
765
|
+
if (!tag) {
|
|
766
|
+
const sniffed = sniffedProtectedLane(task);
|
|
767
|
+
if (sniffed) {
|
|
768
|
+
return {
|
|
769
|
+
eligible: false,
|
|
770
|
+
ref,
|
|
771
|
+
reason: 'untagged_protected_lane_text',
|
|
772
|
+
declared_lane: sniffed.lane,
|
|
773
|
+
matched: sniffed.term,
|
|
774
|
+
next_action: `no tag to route on and the text reads as the ${sniffed.lane} lane; tag it or accept explicitly as a human`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
548
778
|
const proof = latestProof(task);
|
|
549
779
|
if (proofHasUnmergedPullRequestBoundary(proof)) {
|
|
550
780
|
return unmergedPullRequestBoundaryResult(ref, proof);
|
|
@@ -570,8 +800,12 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
570
800
|
// into "lands unchecked" the morning after it clears the worktree.
|
|
571
801
|
// A check that merely isn't in the runnable allowlist counts as no
|
|
572
802
|
// check at all.
|
|
803
|
+
// verify_unrunnable blocks for the same reason worktree_missing does:
|
|
804
|
+
// it used to arrive here as verify_failed, so leaving it out would turn
|
|
805
|
+
// "the harness could not run the check" into "landed unchecked".
|
|
573
806
|
if ((tierRequiresStrictVerify && !verifyResult.ok)
|
|
574
807
|
|| verifyResult.reason === 'verify_failed'
|
|
808
|
+
|| verifyResult.reason === 'verify_unrunnable'
|
|
575
809
|
|| verifyResult.reason === 'verify_worktree_missing') {
|
|
576
810
|
return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
|
|
577
811
|
}
|
|
@@ -579,6 +813,19 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
579
813
|
if (trustTier === 'probation' && !reviewIntegrity.hasIndependentReview(task)) {
|
|
580
814
|
return { eligible: false, ref, reason: 'probation_needs_review' };
|
|
581
815
|
}
|
|
816
|
+
// The read-only status path (executeVerify:false) must apply the same
|
|
817
|
+
// command allowlist the landing gate applies, or status promises a landing
|
|
818
|
+
// the gate will refuse every hour: five rows read "lands itself" for a
|
|
819
|
+
// whole night while every tick skipped them as verify_command_not_allowed.
|
|
820
|
+
// Sits after the probation gate so it reports the gate's own refusal
|
|
821
|
+
// order, not a new one.
|
|
822
|
+
if (verify && !executeVerify && !isAutoCertifyVerifyCommandAllowed(verify)) {
|
|
823
|
+
return { eligible: false, ref, reason: 'verify_command_not_allowed', verify };
|
|
824
|
+
}
|
|
825
|
+
if (executeVerify) {
|
|
826
|
+
const hygieneBlock = hygieneBlockResult(task, ref);
|
|
827
|
+
if (hygieneBlock) return hygieneBlock;
|
|
828
|
+
}
|
|
582
829
|
return {
|
|
583
830
|
eligible: true,
|
|
584
831
|
ref,
|
|
@@ -646,9 +893,18 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
646
893
|
if (!verifyResult.ok) {
|
|
647
894
|
return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
|
|
648
895
|
}
|
|
896
|
+
} else if (!isAutoCertifyVerifyCommandAllowed(verify)) {
|
|
897
|
+
// Same truth rule as above: the read-only path may not promise a
|
|
898
|
+
// landing the allowlist will refuse.
|
|
899
|
+
return { eligible: false, ref, reason: 'verify_command_not_allowed', verify };
|
|
649
900
|
}
|
|
650
901
|
}
|
|
651
902
|
|
|
903
|
+
if (executeVerify) {
|
|
904
|
+
const hygieneBlock = hygieneBlockResult(task, ref);
|
|
905
|
+
if (hygieneBlock) return hygieneBlock;
|
|
906
|
+
}
|
|
907
|
+
|
|
652
908
|
return {
|
|
653
909
|
eligible: true,
|
|
654
910
|
ref,
|
|
@@ -663,12 +919,14 @@ function evaluateAutoAccept(task, options = {}) {
|
|
|
663
919
|
|
|
664
920
|
module.exports = {
|
|
665
921
|
AGENT_CERTIFICATION_REVIEW_PASSES,
|
|
666
|
-
AUTO_ACCEPT_HIGH_CONFIDENCE_PASSES,
|
|
667
922
|
DENIED_TAGS,
|
|
923
|
+
declaredProtectedLane,
|
|
924
|
+
sniffedProtectedLane,
|
|
668
925
|
evaluateAutoAccept,
|
|
669
926
|
isAutoCertifyVerifyCommandAllowed,
|
|
670
927
|
isAgentCertified,
|
|
671
928
|
parseVerifyCommand,
|
|
929
|
+
repoHygieneGate,
|
|
672
930
|
runVerifyCommand,
|
|
673
931
|
runVerifyCommandCached,
|
|
674
932
|
};
|
package/lib/autoland.js
CHANGED
|
@@ -120,6 +120,66 @@ function liveAcceptAuthorization(root = process.cwd()) {
|
|
|
120
120
|
return { ok: true, actor, policy: 'autoland', strictVerify: policy.strict_verify !== false };
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Tick receipts are local file reads and prove the loop ran. A receipt from
|
|
124
|
+
// the last couple of hours means the hourly pass is alive; older or missing
|
|
125
|
+
// means surfaces must not promise "next tick" landing.
|
|
126
|
+
const HEARTBEAT_LIVE_HOURS = 2;
|
|
127
|
+
const STALE_HEARTBEAT_LANDING = 'once the hourly heartbeat runs (start one with atris autoland tick)';
|
|
128
|
+
|
|
129
|
+
function lastTickAgeHours(root) {
|
|
130
|
+
try {
|
|
131
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
132
|
+
const newest = fs.readdirSync(runsDir)
|
|
133
|
+
.filter((f) => f.startsWith('autoland-tick-') && f.endsWith('.json'))
|
|
134
|
+
.sort()
|
|
135
|
+
.pop();
|
|
136
|
+
if (!newest) return null;
|
|
137
|
+
const stamp = fs.statSync(path.join(runsDir, newest)).mtimeMs;
|
|
138
|
+
return (Date.now() - stamp) / 3_600_000;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function heartbeatIsLive(root) {
|
|
145
|
+
const ageHours = lastTickAgeHours(root);
|
|
146
|
+
return ageHours !== null && ageHours <= HEARTBEAT_LIVE_HOURS;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function heartbeatLiveness(root, policy) {
|
|
150
|
+
return typeof policy?.heartbeat_installed === 'boolean' ? policy.heartbeat_installed : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function heartbeatStatusText(root, policy) {
|
|
154
|
+
const ageHours = lastTickAgeHours(root);
|
|
155
|
+
const ageText = ageHours === null
|
|
156
|
+
? null
|
|
157
|
+
: ageHours < 1 ? 'under an hour' : `${Math.floor(ageHours)}h`;
|
|
158
|
+
// Evidence first: a receipt from the last couple of hours proves the loop is
|
|
159
|
+
// alive no matter what the policy file remembers. The hourly cron makes two
|
|
160
|
+
// missed hours an outage rather than jitter — and a heartbeat that has gone
|
|
161
|
+
// quiet is the single most useful thing this line can say, because that is
|
|
162
|
+
// the state nobody notices.
|
|
163
|
+
if (ageHours !== null && ageHours <= HEARTBEAT_LIVE_HOURS) return `running hourly (last tick ${ageText} ago)`;
|
|
164
|
+
if (ageHours !== null) return `SILENT - last tick ${ageText} ago; run atris autoland tick`;
|
|
165
|
+
const installed = heartbeatLiveness(root, policy);
|
|
166
|
+
if (installed === false) return 'not installed - run atris autoland on';
|
|
167
|
+
if (installed === true) return 'installed, but no tick has ever run - run atris autoland tick';
|
|
168
|
+
return 'unknown - run atris autoland on to check and repair';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Operator-facing schedule: only promise "next tick" when a recent receipt
|
|
172
|
+
// proves the loop is alive. Otherwise name the honest gate.
|
|
173
|
+
function whenAutolandLands(root) {
|
|
174
|
+
return heartbeatIsLive(root) ? 'on the next tick' : STALE_HEARTBEAT_LANDING;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function certifiedWorkLandsPhrase(root) {
|
|
178
|
+
return heartbeatIsLive(root)
|
|
179
|
+
? 'certified work lands itself'
|
|
180
|
+
: `certified work lands ${STALE_HEARTBEAT_LANDING}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
123
183
|
function cronMarker(root) {
|
|
124
184
|
const slug = path.basename(root).replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
|
|
125
185
|
return `ATRIS_AUTOLAND_${slug}`;
|
|
@@ -654,22 +714,25 @@ function sendImessage(root, to, text) {
|
|
|
654
714
|
module.exports = {
|
|
655
715
|
DEFAULT_ALARM_HOURS,
|
|
656
716
|
DEFAULT_DIGEST_HOUR,
|
|
717
|
+
STALE_HEARTBEAT_LANDING,
|
|
657
718
|
acceptedInLastDay,
|
|
658
719
|
buildCronLine,
|
|
720
|
+
certifiedWorkLandsPhrase,
|
|
659
721
|
clarify,
|
|
660
722
|
composeAlarm,
|
|
661
723
|
composeDigest,
|
|
662
724
|
composeLiveUpdate,
|
|
663
725
|
cronInstalled,
|
|
664
|
-
cronMarker,
|
|
665
726
|
dueForAlarm,
|
|
666
727
|
digestLine,
|
|
667
728
|
dejargon,
|
|
668
729
|
explainResult,
|
|
730
|
+
heartbeatIsLive,
|
|
731
|
+
heartbeatLiveness,
|
|
732
|
+
heartbeatStatusText,
|
|
669
733
|
installCron,
|
|
670
734
|
liveAcceptAuthorization,
|
|
671
735
|
markAlerted,
|
|
672
|
-
missionDigestLine,
|
|
673
736
|
operatorReady,
|
|
674
737
|
hasAgentJargon,
|
|
675
738
|
voicePatterns: Object.freeze({
|
|
@@ -685,8 +748,8 @@ module.exports = {
|
|
|
685
748
|
sendImessage,
|
|
686
749
|
statePath,
|
|
687
750
|
uninstallCron,
|
|
688
|
-
waitingHours,
|
|
689
751
|
waitingOnHuman,
|
|
752
|
+
whenAutolandLands,
|
|
690
753
|
writePolicy,
|
|
691
754
|
writeState,
|
|
692
755
|
};
|