atris 3.64.0 → 3.66.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.
@@ -6,7 +6,8 @@ const path = require('path');
6
6
  const { taskProofState, taskProofExecutionState } = require('./task-proof');
7
7
  const { extractReceiptEvidence } = require('./receipt-evidence');
8
8
  const reviewIntegrity = require('./review-integrity');
9
- const { computeTrustTier } = require('./trust-tiers');
9
+ const { computeTrustTier, taskTrustSubject } = require('./trust-tiers');
10
+ const { rewriteWorktreeVerify, missingOnMain } = require('./verify-worktree-rewrite');
10
11
  const { policyLessonsForFiles, readPolicyLessons } = require('./policy-lessons');
11
12
  const { skillEvalGate } = require('./skill-eval-gate');
12
13
 
@@ -457,8 +458,61 @@ function parseVerifyCommand(verify) {
457
458
  };
458
459
  }
459
460
 
461
+ // Members record checks as one command or several joined with `&&`
462
+ // ("npm run test:a && node scripts/test-b.mjs"). Each step must pass the
463
+ // same allowlist on its own; `&&` only runs them in order and stops at the
464
+ // first failure, still with shell:false, so a chain can do nothing its steps
465
+ // could not do one at a time. One leading `cd` applies to every step.
466
+ const MAX_VERIFY_CHAIN_STEPS = 8;
467
+
468
+ function splitTopLevelAnd(command) {
469
+ const parts = [];
470
+ let current = '';
471
+ let quote = null;
472
+ for (let i = 0; i < command.length; i += 1) {
473
+ const char = command[i];
474
+ if (quote) {
475
+ if (char === quote) quote = null;
476
+ current += char;
477
+ continue;
478
+ }
479
+ if (char === "'" || char === '"') {
480
+ quote = char;
481
+ current += char;
482
+ continue;
483
+ }
484
+ if (char === '&' && command[i + 1] === '&') {
485
+ parts.push(current.trim());
486
+ current = '';
487
+ i += 1;
488
+ continue;
489
+ }
490
+ current += char;
491
+ }
492
+ parts.push(current.trim());
493
+ return parts;
494
+ }
495
+
496
+ function parseVerifyChain(verify) {
497
+ const cmd = String(verify || '').trim();
498
+ if (!cmd) return { ok: false, reason: 'no_verify_command' };
499
+ const prefixed = parseCdPrefix(cmd);
500
+ if (!prefixed.ok) return prefixed;
501
+ const parts = splitTopLevelAnd(prefixed.command);
502
+ if (parts.length > MAX_VERIFY_CHAIN_STEPS || parts.some((part) => !part || /^cd(\s|$)/.test(part))) {
503
+ return { ok: false, reason: 'verify_command_not_allowed' };
504
+ }
505
+ const steps = [];
506
+ for (const part of parts) {
507
+ const parsed = parseVerifyCommand(part);
508
+ if (!parsed.ok) return parsed;
509
+ steps.push({ ...parsed, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) });
510
+ }
511
+ return { ok: true, steps, ...(prefixed.cwd ? { cwd: prefixed.cwd } : {}) };
512
+ }
513
+
460
514
  function isAutoCertifyVerifyCommandAllowed(verify) {
461
- return parseVerifyCommand(verify).ok;
515
+ return parseVerifyChain(verify).ok;
462
516
  }
463
517
 
464
518
  // Re-entrancy guard: every verify child carries ATRIS_VERIFY_IN_PROGRESS=1
@@ -468,12 +522,20 @@ function isAutoCertifyVerifyCommandAllowed(verify) {
468
522
  // `atris task status` and the read path re-ran the verify. Refuse loudly.
469
523
  const VERIFY_IN_PROGRESS_ENV = 'ATRIS_VERIFY_IN_PROGRESS';
470
524
 
471
- // Spawn cap: a single CLI process has no legitimate reason to run dozens of
472
- // verifies. Beyond the cap something is looping; refuse and report instead
473
- // of silently bounding.
474
- const VERIFY_SPAWN_CAP = 25;
525
+ // Spawn cap: bounds a runaway loop inside one process. The recursion that
526
+ // fork-bombed the fleet is stopped by VERIFY_IN_PROGRESS_ENV above; this cap
527
+ // is the backstop. It was 25, which starved the review queue: every hourly
528
+ // tick re-checked the same first 25 rows and the other 141 waiting rows were
529
+ // refused as verify_spawn_cap forever (measured 2026-10-01). Override with
530
+ // ATRIS_VERIFY_SPAWN_CAP.
531
+ const DEFAULT_VERIFY_SPAWN_CAP = 150;
475
532
  let verifySpawnCount = 0;
476
533
 
534
+ function verifySpawnCap() {
535
+ const raw = Number(process.env.ATRIS_VERIFY_SPAWN_CAP);
536
+ return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 1000) : DEFAULT_VERIFY_SPAWN_CAP;
537
+ }
538
+
477
539
  function verifyRefusal(unrunnableCause, extra) {
478
540
  return {
479
541
  ok: false,
@@ -490,14 +552,26 @@ function runVerifyCommand(verify, workspaceRoot) {
490
552
  detail: `${VERIFY_IN_PROGRESS_ENV} is set: a verify is already running up the call chain; refusing to nest another one`,
491
553
  });
492
554
  }
493
- if (verifySpawnCount >= VERIFY_SPAWN_CAP) {
555
+ const chain = parseVerifyChain(verify);
556
+ if (!chain.ok) return chain;
557
+ let result = null;
558
+ for (const step of chain.steps) {
559
+ result = runVerifyStep(step, workspaceRoot);
560
+ if (!result.ok) {
561
+ return chain.steps.length > 1 ? { ...result, failed_step: step.argv.join(' ') } : result;
562
+ }
563
+ }
564
+ return result;
565
+ }
566
+
567
+ function runVerifyStep(parsed, workspaceRoot) {
568
+ const cap = verifySpawnCap();
569
+ if (verifySpawnCount >= cap) {
494
570
  return verifyRefusal('verify_spawn_cap', {
495
- detail: `this process already ran ${verifySpawnCount} verifies (cap ${VERIFY_SPAWN_CAP}); refusing more`,
571
+ detail: `this process already ran ${verifySpawnCount} verifies (cap ${cap}); refusing more`,
496
572
  });
497
573
  }
498
574
  verifySpawnCount += 1;
499
- const parsed = parseVerifyCommand(verify);
500
- if (!parsed.ok) return parsed;
501
575
  const cwdCheck = validateCommandCwd(parsed, workspaceRoot);
502
576
  if (!cwdCheck.ok) return cwdCheck;
503
577
  const gitPathCheck = validateGitWorktreePath(parsed.argv, workspaceRoot, cwdCheck.cwd);
@@ -1018,6 +1092,79 @@ function sniffedProtectedLane(task) {
1018
1092
  return null;
1019
1093
  }
1020
1094
 
1095
+ // The protected lane a task sits in, or null: a denied tag (whole word,
1096
+ // plural-stripped, the same match the landing gate uses), a lane declared in
1097
+ // its text, or, for untagged rows, lane terms in its title or objective.
1098
+ // Every automatic closer must leave these for a human.
1099
+ function deniedTagOf(task) {
1100
+ const tag = String((task && task.tag) || '').trim().toLowerCase();
1101
+ return [...DENIED_TAGS].find((d) =>
1102
+ tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d)) || null;
1103
+ }
1104
+
1105
+ function protectedLaneOf(task) {
1106
+ if (!task || typeof task !== 'object') return null;
1107
+ const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
1108
+ const view = { ...task, objective: task.objective || metadata.objective || '', metadata };
1109
+ const denied = deniedTagOf(view);
1110
+ if (denied) return denied;
1111
+ if (declaredProtectedLane(view)) return 'declared';
1112
+ if (!String(view.tag || '').trim()) {
1113
+ const sniffed = sniffedProtectedLane(view);
1114
+ if (sniffed) return sniffed.lane;
1115
+ }
1116
+ return null;
1117
+ }
1118
+
1119
+ // The check the landing pass actually runs. A check recorded inside an
1120
+ // agent's temporary copy is rewritten to run from the main checkout; when the
1121
+ // file it runs only exists on the agent's branch, that is reported in plain
1122
+ // words instead of a bare "not on the list this machine may run".
1123
+ function resolveTaskVerify(task) {
1124
+ const recorded = String((task.metadata || {}).verify || '').trim();
1125
+ if (!recorded) return { verify: '', recorded: '', rewritten: false, missing: null };
1126
+ const root = task.workspace_root || process.cwd();
1127
+ const rewrite = rewriteWorktreeVerify(recorded, root);
1128
+ const verify = rewrite.command || recorded;
1129
+ let missing = null;
1130
+ if (rewrite.rewritten) {
1131
+ const chain = parseVerifyChain(verify);
1132
+ if (chain.ok) missing = missingOnMain(chain.steps, root);
1133
+ }
1134
+ return { verify, recorded, rewritten: rewrite.rewritten, missing };
1135
+ }
1136
+
1137
+ // Why the landing pass could never rerun this task's check from main, or
1138
+ // null when it can. Independent of the gate's refusal order, so a row held
1139
+ // for another reason still reports a check that can never run.
1140
+ function verifyRunnability(task) {
1141
+ const resolved = resolveTaskVerify(task);
1142
+ if (!resolved.verify) return 'no_verify_command';
1143
+ if (!isAutoCertifyVerifyCommandAllowed(resolved.verify)) return 'verify_command_not_allowed';
1144
+ if (resolved.missing) return 'verify_only_on_agent_branch';
1145
+ return null;
1146
+ }
1147
+
1148
+ function agentBranchOnlyResult(ref, resolved) {
1149
+ return {
1150
+ eligible: false,
1151
+ ref,
1152
+ reason: 'verify_only_on_agent_branch',
1153
+ verify: resolved.verify,
1154
+ recorded_verify: resolved.recorded,
1155
+ missing_path: resolved.missing,
1156
+ next_action: `${resolved.missing} is not on main yet; land the agent branch that has it, then the check reruns from main`,
1157
+ };
1158
+ }
1159
+
1160
+ function runResolvedVerify(resolved, workspaceRoot, cache) {
1161
+ const result = runVerifyCommandCached(resolved.verify, workspaceRoot, cache);
1162
+ if (resolved.rewritten && result.reason === 'verify_failed') {
1163
+ return { ...result, reason: 'verify_failed_on_main', recorded_verify: resolved.recorded };
1164
+ }
1165
+ return resolved.rewritten ? { ...result, recorded_verify: resolved.recorded } : result;
1166
+ }
1167
+
1021
1168
  function evaluateAutoAccept(task, options = {}) {
1022
1169
  const {
1023
1170
  strictVerify = true,
@@ -1030,10 +1177,9 @@ function evaluateAutoAccept(task, options = {}) {
1030
1177
  if (task.status !== 'review') return { eligible: false, ref, reason: 'not_in_review' };
1031
1178
  const metadata = task.metadata || {};
1032
1179
  const review = task.review || {};
1033
- const trustTier = computeTrustTier(
1034
- task.claimed_by || metadata.executed_by,
1035
- task.workspace_root || process.cwd(),
1036
- );
1180
+ // Trust is the builder's, never the approver's: a task built by a member
1181
+ // with a clean record lands without waiting on a human look.
1182
+ const trustTier = computeTrustTier(taskTrustSubject(task), task.workspace_root || process.cwd());
1037
1183
  const tierRequiresStrictVerify = trustTier !== 'trusted';
1038
1184
  const requiresStrictVerify = strictVerify || tierRequiresStrictVerify;
1039
1185
  const approval = String(review.approval_status || metadata.approval_status || 'pending').toLowerCase();
@@ -1107,10 +1253,12 @@ function evaluateAutoAccept(task, options = {}) {
1107
1253
  }
1108
1254
  const evidenceBlock = receiptEvidenceBlock(task, ref);
1109
1255
  if (evidenceBlock) return evidenceBlock;
1110
- const verify = metadata.verify;
1256
+ const resolvedVerify = resolveTaskVerify(task);
1257
+ const verify = resolvedVerify.verify;
1111
1258
  if (tierRequiresStrictVerify && !verify) return strictVerifyMissingResult(ref);
1259
+ if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1112
1260
  if (verify && executeVerify) {
1113
- const verifyResult = runVerifyCommandCached(verify, task.workspace_root || process.cwd(), verifyCache);
1261
+ const verifyResult = runResolvedVerify(resolvedVerify, task.workspace_root || process.cwd(), verifyCache);
1114
1262
  // A check that runs and fails blocks. So does a check whose worktree
1115
1263
  // is gone, otherwise the daily reap converts "has a failing check"
1116
1264
  // into "lands unchecked" the morning after it clears the worktree.
@@ -1121,6 +1269,7 @@ function evaluateAutoAccept(task, options = {}) {
1121
1269
  // "the harness could not run the check" into "landed unchecked".
1122
1270
  if ((tierRequiresStrictVerify && !verifyResult.ok)
1123
1271
  || verifyResult.reason === 'verify_failed'
1272
+ || verifyResult.reason === 'verify_failed_on_main'
1124
1273
  || verifyResult.reason === 'verify_unrunnable'
1125
1274
  || verifyResult.reason === 'verify_worktree_missing'
1126
1275
  || verifyResult.reason === 'verify_workdir_missing') {
@@ -1206,11 +1355,13 @@ function evaluateAutoAccept(task, options = {}) {
1206
1355
  }
1207
1356
 
1208
1357
  if (requiresStrictVerify) {
1209
- const verify = metadata.verify;
1358
+ const resolvedVerify = resolveTaskVerify(task);
1359
+ const verify = resolvedVerify.verify;
1210
1360
  if (!verify) return strictVerifyMissingResult(ref);
1361
+ if (resolvedVerify.missing) return agentBranchOnlyResult(ref, resolvedVerify);
1211
1362
  if (executeVerify) {
1212
1363
  const workspaceRoot = task.workspace_root || process.cwd();
1213
- const verifyResult = runVerifyCommandCached(verify, workspaceRoot, verifyCache);
1364
+ const verifyResult = runResolvedVerify(resolvedVerify, workspaceRoot, verifyCache);
1214
1365
  if (!verifyResult.ok) {
1215
1366
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
1216
1367
  }
@@ -1248,8 +1399,11 @@ module.exports = {
1248
1399
  candidatePolicyGate,
1249
1400
  declaredProtectedLane,
1250
1401
  sniffedProtectedLane,
1402
+ protectedLaneOf,
1403
+ verifyRunnability,
1251
1404
  evaluateAutoAccept,
1252
1405
  isAutoCertifyVerifyCommandAllowed,
1406
+ parseVerifyChain,
1253
1407
  isAgentCertified,
1254
1408
  parseVerifyCommand,
1255
1409
  repoHygieneGate,
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for business-scoped HTTP commands (`atris hire`, `atris hires`,
4
+ // `atris mail`): parse flags, find which business to act on, get a login token.
5
+ // These commands are HTTP clients of /api/business/{id}/...; no backend code here.
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { ensureValidCredentials } = require('../utils/auth');
9
+ const { findBusiness } = require('../commands/feed');
10
+ const { oneLine } = require('./developer-api');
11
+
12
+ const NO_BUSINESS = 'no business here. run this inside a business folder, or add --business <slug>';
13
+
14
+ // --help, --json and --business are allowed on every command.
15
+ const COMMON_SWITCHES = ['help', 'json'];
16
+ const COMMON_VALUES = ['business'];
17
+
18
+ // --key value, --key=value, and yes/no switches that never take a value.
19
+ // `spec` is { switches: [...], values: [...] }: the only flags the command
20
+ // takes, beyond the common ones. Anything else comes back as `error`, and
21
+ // the command must stop before making any request:
22
+ // an unknown flag (--dry-run on a command without it),
23
+ // a value on a switch (--yes=false is refused, never read as yes),
24
+ // a value flag with nothing after it (--business ""),
25
+ // the same value flag twice.
26
+ // A plain array is the old lenient form (switch names only), kept for callers
27
+ // that want it; it never reports errors.
28
+ function parseArgs(argv = [], spec = []) {
29
+ const strict = !Array.isArray(spec);
30
+ const switchNames = strict ? (spec.switches || []) : spec;
31
+ const valueNames = strict ? (spec.values || []) : [];
32
+ const switches = new Set([...switchNames, ...COMMON_SWITCHES, 'yes']);
33
+ const allowed = new Set([...switchNames, ...valueNames, ...COMMON_SWITCHES, ...COMMON_VALUES]);
34
+ const flags = {};
35
+ const pos = [];
36
+ let error = '';
37
+ const fail = (message) => { if (!error) error = message; };
38
+ for (let i = 0; i < argv.length; i += 1) {
39
+ const arg = String(argv[i]);
40
+ if (arg === '-h') { flags.help = true; continue; }
41
+ if (arg === '-y') {
42
+ if (strict && !allowed.has('yes')) fail('unknown option -y');
43
+ else flags.yes = true;
44
+ continue;
45
+ }
46
+ if (arg.startsWith('--')) {
47
+ const eq = arg.indexOf('=');
48
+ const raw = eq > 0 ? arg.slice(0, eq) : arg;
49
+ const key = raw.slice(2).replace(/-/g, '_');
50
+ if (strict && !allowed.has(key)) { fail(`unknown option ${raw}`); continue; }
51
+ if (switches.has(key)) {
52
+ if (eq < 0) flags[key] = true;
53
+ else if (strict) fail(`${raw} is a switch and takes no value: use ${raw} on its own, or leave it off`);
54
+ else flags[key] = arg.slice(eq + 1);
55
+ continue;
56
+ }
57
+ let value = true;
58
+ if (eq > 0) value = arg.slice(eq + 1);
59
+ else if (argv[i + 1] != null && !String(argv[i + 1]).startsWith('--')) { value = String(argv[i + 1]); i += 1; }
60
+ if (strict && (typeof value !== 'string' || !value.trim())) { fail(`${raw} needs a value`); continue; }
61
+ if (strict && Object.prototype.hasOwnProperty.call(flags, key)) { fail(`${raw} was given twice`); continue; }
62
+ flags[key] = value;
63
+ continue;
64
+ }
65
+ if (strict && arg.length > 1 && arg.startsWith('-')) { fail(`unknown option ${arg}`); continue; }
66
+ pos.push(arg);
67
+ }
68
+ return strict ? { flags, pos, error } : { flags, pos };
69
+ }
70
+
71
+ // A --business that was given must name a business. An empty or missing
72
+ // value is an error and never falls back to the current folder's business.
73
+ // Without --business: the nearest .atris/business.json above the current
74
+ // folder. Returns { id, name }, { error }, or null when none is found.
75
+ function resolveBusiness(flags = {}, deps = {}) {
76
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
77
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
78
+ if (!given) return { error: '--business needs a business slug or id' };
79
+ return { id: given, name: given };
80
+ }
81
+ const found = (deps.findBusiness || findBusiness)(deps.cwd || process.cwd());
82
+ return found && found.businessId ? { id: found.businessId, name: found.name || 'this business' } : null;
83
+ }
84
+
85
+ // The business to act on, or says why not (through `err`) and returns null.
86
+ function businessOrSay(flags, deps, err) {
87
+ const business = resolveBusiness(flags, deps);
88
+ if (!business) { err(NO_BUSINESS); return null; }
89
+ if (business.error) { err(business.error); return null; }
90
+ return business;
91
+ }
92
+
93
+ async function loginToken(deps = {}) {
94
+ if (deps.token) return deps.token;
95
+ const ensure = deps.ensureCredentials || (() => ensureValidCredentials(apiRequestJson));
96
+ const auth = await ensure();
97
+ return auth && !auth.error && auth.credentials && auth.credentials.token ? auth.credentials.token : '';
98
+ }
99
+
100
+ // FastAPI errors arrive as {"detail": "..."}; keep them to one plain line.
101
+ function errorText(result) {
102
+ if (!result) return 'request failed';
103
+ if (Number(result.status) === 404 && !(result.data && result.data.detail)) {
104
+ return 'backend rolling out, try again shortly';
105
+ }
106
+ return oneLine((result.data && (result.data.detail || result.data.error)) || result.error || `request failed (${result.status})`);
107
+ }
108
+
109
+ // "2026-10-02T09:00:00+00:00" -> "in 9 h" / "6 h ago"
110
+ function relativeTime(iso, now = Date.now()) {
111
+ const at = Date.parse(iso || '');
112
+ if (!Number.isFinite(at)) return '';
113
+ const mins = Math.round((at - now) / 60000);
114
+ const abs = Math.abs(mins);
115
+ const span = abs < 1 ? 'now' : abs < 90 ? `${abs} min` : abs < 48 * 60 ? `${Math.round(abs / 60)} h` : `${Math.round(abs / 1440)} days`;
116
+ if (span === 'now') return 'just now';
117
+ return mins >= 0 ? `in ${span}` : `${span} ago`;
118
+ }
119
+
120
+ function dollars(value) {
121
+ if (value == null || !Number.isFinite(Number(value))) return '-';
122
+ const n = Number(value);
123
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
124
+ }
125
+
126
+ function clip(text, width) {
127
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
128
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
129
+ }
130
+
131
+ module.exports = { NO_BUSINESS, parseArgs, resolveBusiness, businessOrSay, loginToken, errorText, relativeTime, dollars, clip };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // One plain line per cleanup run, in the daily journal's "## <project>
4
+ // overnight" section: the hourly autoland tick writes its own line there and
5
+ // so do project jobs (project-obelisk's review sweep and state sync). The
6
+ // session start screen reads the latest one back, so the owner sees what the
7
+ // background work did without opening anything.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const CLEANUP_WORDS = /\b(landed|land on|closed|put away|parked|need you|reran|archived)\b/i;
13
+
14
+ function localDate(now) {
15
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
16
+ }
17
+
18
+ function journalFile(root, now) {
19
+ return path.join(root, 'atris', 'logs', String(now.getFullYear()), `${localDate(now)}.md`);
20
+ }
21
+
22
+ function sectionName(root) {
23
+ return `${path.basename(path.resolve(root)).replace(/^project-/, '')} overnight`;
24
+ }
25
+
26
+ // Same text twice in one day is written once.
27
+ function appendOvernightNote(root, line, now = new Date()) {
28
+ const text = String(line || '').trim();
29
+ if (!text || !fs.existsSync(path.join(root, 'atris'))) return false;
30
+ const file = journalFile(root, now);
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ const body = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
33
+ const bullet = `- ${text}`;
34
+ if (body.split('\n').some((l) => l.trim() === bullet)) return false;
35
+ const marker = `## ${sectionName(root)}`;
36
+ const idx = body.split('\n').findIndex((l) => l.trim() === marker);
37
+ let next;
38
+ if (idx === -1) {
39
+ next = `${body}${body && !body.endsWith('\n') ? '\n' : ''}\n${marker}\n${bullet}\n`;
40
+ } else {
41
+ const lines = body.split('\n');
42
+ let end = idx + 1;
43
+ while (end < lines.length && !lines[end].startsWith('## ')) end += 1;
44
+ let insertAt = end;
45
+ while (insertAt > idx + 1 && !lines[insertAt - 1].trim()) insertAt -= 1;
46
+ lines.splice(insertAt, 0, bullet);
47
+ next = lines.join('\n');
48
+ }
49
+ const tmp = `${file}.tmp-${process.pid}`;
50
+ fs.writeFileSync(tmp, next);
51
+ fs.renameSync(tmp, file);
52
+ return true;
53
+ }
54
+
55
+ function overnightBullets(text) {
56
+ const bullets = [];
57
+ let inSection = false;
58
+ for (const line of String(text || '').split('\n')) {
59
+ if (line.startsWith('## ')) {
60
+ inSection = /\bovernight\s*$/i.test(line.trim());
61
+ continue;
62
+ }
63
+ if (inSection && line.trim().startsWith('- ')) bullets.push(line.trim().slice(2).trim());
64
+ }
65
+ return bullets;
66
+ }
67
+
68
+ function parkedBranchCount(root) {
69
+ try {
70
+ return fs.readFileSync(path.join(root, 'atris', 'reports', 'parked-branches.md'), 'utf8')
71
+ .split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2} · /.test(l)).length;
72
+ } catch {
73
+ return 0;
74
+ }
75
+ }
76
+
77
+ function shorten(text, max) {
78
+ if (text.length <= max) return text;
79
+ const cut = text.slice(0, max - 1);
80
+ const stop = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '));
81
+ return `${(stop > max * 0.5 ? cut.slice(0, stop) : cut).replace(/[\s,;.]+$/, '')}...`;
82
+ }
83
+
84
+ // The newest cleanup line from today's journal (yesterday's if today has
85
+ // none), plus the parked-branch count when the line does not already say it.
86
+ // Two small file reads; never throws.
87
+ function latestCleanupLine(root, { now = new Date(), max = 150 } = {}) {
88
+ try {
89
+ let line = '';
90
+ for (const offset of [0, 1]) {
91
+ const day = new Date(now);
92
+ day.setDate(day.getDate() - offset);
93
+ let text = '';
94
+ try { text = fs.readFileSync(journalFile(root, day), 'utf8'); } catch { continue; }
95
+ const bullets = overnightBullets(text).filter((b) => CLEANUP_WORDS.test(b));
96
+ if (bullets.length) { line = bullets[bullets.length - 1]; break; }
97
+ }
98
+ const parked = parkedBranchCount(root);
99
+ if (parked && !/parked/i.test(line)) {
100
+ const note = `${parked} agent branch${parked === 1 ? '' : 'es'} parked for you`;
101
+ line = line ? `${shorten(line.replace(/\.$/, ''), max - note.length - 2)}; ${note}` : `${note}.`;
102
+ }
103
+ return line ? shorten(line, max) : '';
104
+ } catch {
105
+ return '';
106
+ }
107
+ }
108
+
109
+ function plural(n, word, many = `${word}s`) {
110
+ return `${n} ${n === 1 ? word : many}`;
111
+ }
112
+
113
+ // The autoland tick's own line. Null when the tick did nothing worth a line.
114
+ function tickSummaryLine({ landed = 0, closed = 0, putAway = 0, needYou = 0 } = {}) {
115
+ const parts = [];
116
+ if (landed) parts.push(`landed ${plural(landed, 'finished item')}`);
117
+ if (closed) parts.push(`closed ${plural(closed, 'idle item')} that could never land on their own, each with a plain reason`);
118
+ if (putAway) parts.push(`put away ${plural(putAway, 'done or long-idle item')}`);
119
+ if (!parts.length) return null;
120
+ if (needYou) parts.push(`${needYou} need you (money, deploys, security, or customers)`);
121
+ const text = parts.join('; ');
122
+ return `Autoland: ${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
123
+ }
124
+
125
+ module.exports = {
126
+ appendOvernightNote,
127
+ latestCleanupLine,
128
+ tickSummaryLine,
129
+ };
package/lib/engine-ask.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  } = require('./runner-command');
12
12
  const {
13
13
  canonicalEngineName,
14
+ currentClaudeModel,
14
15
  engineFailureHealthStatus,
15
16
  setEngineHealth,
16
17
  } = require('./engine-registry');
@@ -241,7 +242,7 @@ function assertAskModelSupported(engine, model) {
241
242
 
242
243
  function resolveAskModel(engine, modelName = '') {
243
244
  const requested = String(modelName || '').trim();
244
- if (requested) return requested;
245
+ if (requested) return currentClaudeModel(requested);
245
246
  if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
246
247
  return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
247
248
  }
@@ -291,7 +292,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
291
292
  return {
292
293
  engine,
293
294
  bin: profile.bin,
294
- args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
295
+ args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
295
296
  };
296
297
  }
297
298
  if (engine === 'cursor') {