atris 3.70.0 → 3.71.1
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 +18 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +246 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-run-lock.js +59 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +395 -18
- package/lib/member-board.js +7 -1
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +127 -8
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +47 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/commands/init.js
CHANGED
|
@@ -5,6 +5,7 @@ const { ensureExperimentsFramework } = require('./experiments');
|
|
|
5
5
|
const { ensureWorkspaceBrain } = require('../lib/workspace-scaffold');
|
|
6
6
|
const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
|
|
7
7
|
const { ensureRunIgnore } = require('../lib/gitignore');
|
|
8
|
+
const { snapshotBeforeInit, commitInitFiles } = require('../lib/init-commit');
|
|
8
9
|
const { INTENTS } = require('../lib/intents');
|
|
9
10
|
const {
|
|
10
11
|
upsertAgentVoiceCard,
|
|
@@ -307,7 +308,7 @@ function initAtris() {
|
|
|
307
308
|
// shouldn't have file-system side effects.
|
|
308
309
|
const args = process.argv.slice(3);
|
|
309
310
|
if (args[0] === '-h' || args[0] === '--help' || args[0] === 'help') {
|
|
310
|
-
console.log('Usage: atris init [--force] [--yes|-y] [--verbose] [--minimal] [--full] [--with-adapters] [--with-skills]');
|
|
311
|
+
console.log('Usage: atris init [--force] [--yes|-y] [--verbose] [--minimal] [--full] [--with-adapters] [--with-skills] [--no-commit]');
|
|
311
312
|
console.log('');
|
|
312
313
|
console.log(' Scaffold the atris/ workspace in the current directory.');
|
|
313
314
|
console.log(' Refuses to run inside an existing atris/ folder unless --force is passed.');
|
|
@@ -315,6 +316,8 @@ function initAtris() {
|
|
|
315
316
|
console.log(' Use --verbose to list every file installed.');
|
|
316
317
|
console.log(' Use --minimal for atris.md + MAP + TODO + today journal + .atris/state (no IDE farm).');
|
|
317
318
|
console.log(' Use --with-adapters / --with-skills with --minimal, or --full for the default full scaffold.');
|
|
319
|
+
console.log(' In a git repo with no commits yet, init commits the files it creates so every member copy starts with them.');
|
|
320
|
+
console.log(' Use --no-commit to leave them uncommitted.');
|
|
318
321
|
return;
|
|
319
322
|
}
|
|
320
323
|
const verbose = args.includes('--verbose');
|
|
@@ -392,6 +395,9 @@ function initAtris() {
|
|
|
392
395
|
walker = parent;
|
|
393
396
|
}
|
|
394
397
|
|
|
398
|
+
// Snapshot what was already uncommitted so the setup commit only carries init's own files.
|
|
399
|
+
const gitSnapshot = args.includes('--no-commit') ? null : snapshotBeforeInit(cwd);
|
|
400
|
+
|
|
395
401
|
const targetDir = path.join(cwd, 'atris');
|
|
396
402
|
const teamDir = path.join(targetDir, 'team');
|
|
397
403
|
const legacyAgentTeamDir = path.join(targetDir, 'agent_team');
|
|
@@ -1122,6 +1128,17 @@ Read atris/MAP.md. Begin iteration 1.`;
|
|
|
1122
1128
|
fs.copyFileSync(sourceFile, targetFile);
|
|
1123
1129
|
markReady('workspace', 'atris.md', '✓ Copied atris.md to atris/ folder');
|
|
1124
1130
|
printReadyCounts();
|
|
1131
|
+
if (gitSnapshot) {
|
|
1132
|
+
const saved = commitInitFiles(gitSnapshot);
|
|
1133
|
+
if (saved.committed) {
|
|
1134
|
+
console.log('setup saved to git, so every member starts with it');
|
|
1135
|
+
} else if (saved.reason === 'has_history') {
|
|
1136
|
+
console.log('commit the new atris files so members start with them');
|
|
1137
|
+
} else if (saved.reason === 'add_failed' || saved.reason === 'commit_failed') {
|
|
1138
|
+
console.log('setup files are not committed yet. members only see them after you commit.');
|
|
1139
|
+
if (saved.detail) console.log(` git said: ${saved.detail.split('\n')[0]}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1125
1142
|
console.log('\natris initialized.');
|
|
1126
1143
|
console.log('no commands to learn. tell your agent what you want in plain words.');
|
|
1127
1144
|
} else {
|
package/commands/integrations.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* atris gmail archive <id> [...] [--account <id>] - Archive messages
|
|
8
8
|
* atris gmail triage [--account <id>] [--limit N] - Record keep or archive verdicts
|
|
9
9
|
* atris gmail verdicts [--summary] [--account <id>] [--limit N] - List recent Gmail verdicts
|
|
10
|
-
* atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
|
|
10
|
+
* atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - Send an email
|
|
11
11
|
* atris gmail voice [account] [--clear] - Edit an account's writing voice
|
|
12
12
|
* atris gmail connect [name] - Connect or reconnect a Gmail account
|
|
13
13
|
* atris gmail accounts - List connected Gmail accounts and the active account
|
|
@@ -41,7 +41,7 @@ const { sleepSync } = require('../lib/sleep-sync');
|
|
|
41
41
|
const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
|
|
42
42
|
const GMAIL_CONNECT_POLL_MS = 3000;
|
|
43
43
|
const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
|
|
44
|
-
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
|
|
44
|
+
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
|
|
45
45
|
const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
|
|
46
46
|
const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
|
|
47
47
|
const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
|
|
@@ -560,10 +560,15 @@ async function gmailArchive(messageIds, options = {}) {
|
|
|
560
560
|
function parseGmailSendArgs(args = []) {
|
|
561
561
|
let accountId = null;
|
|
562
562
|
let bodyFile = null;
|
|
563
|
+
let approved = false;
|
|
563
564
|
const positional = [];
|
|
564
565
|
|
|
565
566
|
for (let i = 0; i < args.length; i += 1) {
|
|
566
567
|
const arg = args[i];
|
|
568
|
+
if (arg === '--approved' || arg === '--confirm-approved') {
|
|
569
|
+
approved = true;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
567
572
|
if (arg === '--account' || arg === '--body-file') {
|
|
568
573
|
const value = String(args[i + 1] || '').trim();
|
|
569
574
|
if (!value || value.startsWith('--')) {
|
|
@@ -596,10 +601,17 @@ function parseGmailSendArgs(args = []) {
|
|
|
596
601
|
}
|
|
597
602
|
}
|
|
598
603
|
|
|
599
|
-
return { to, subject, body, accountId };
|
|
604
|
+
return { to, subject, body, accountId, approved };
|
|
600
605
|
}
|
|
601
606
|
|
|
602
607
|
async function gmailSend(to, subject, body, options = {}) {
|
|
608
|
+
// Approval is checked before any token lookup or network call so an
|
|
609
|
+
// unapproved send can never reach the server.
|
|
610
|
+
if (!options.approved) {
|
|
611
|
+
console.error('Refusing to send without --approved after the exact recipient, subject, and body are confirmed.');
|
|
612
|
+
console.error(GMAIL_SEND_USAGE);
|
|
613
|
+
process.exit(2);
|
|
614
|
+
}
|
|
603
615
|
const token = await getAuthToken();
|
|
604
616
|
const accountId = resolveGmailAccountId(options.accountId);
|
|
605
617
|
const accounts = await fetchGmailAccounts(token);
|
|
@@ -864,7 +876,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
864
876
|
}
|
|
865
877
|
case 'send': {
|
|
866
878
|
const parsed = parseGmailSendArgs(args);
|
|
867
|
-
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined });
|
|
879
|
+
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined, approved: parsed.approved });
|
|
868
880
|
break;
|
|
869
881
|
}
|
|
870
882
|
case 'voice': {
|
|
@@ -888,7 +900,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
888
900
|
console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
|
|
889
901
|
console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
|
|
890
902
|
console.log(' atris gmail verdicts [--summary] [--account <id>] [--limit N] - list recent gmail verdicts');
|
|
891
|
-
console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
|
|
903
|
+
console.log(' atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - send an email');
|
|
892
904
|
console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
|
|
893
905
|
console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
|
|
894
906
|
console.log(' atris gmail accounts - list connected gmail accounts and the active account');
|
package/commands/interview.js
CHANGED
|
@@ -135,6 +135,7 @@ Full skill (read if present): .claude/skills/interview/SKILL.md · spec: atris/f
|
|
|
135
135
|
const modes = {
|
|
136
136
|
operator: `Mode: OPERATOR WARM START. Interview the human at the keyboard. Open with the sharpest observed behavior in their recent journal/logs, especially repeated manual actions (unextracted members) and policies their own behavior contradicts. Output: a new member, an amendment, or a policy change.`,
|
|
137
137
|
member: `Mode: MEMBER RE-INTERVIEW (performance review). Subject: the team member "${subject}". Read its folder (MEMBER.md, logs/, now.md, proofs) and interview the human ABOUT it: where it failed, what it should defend, what taste is missing from its file. Honest failure → document the lesson, no penalty. A false "done" is the one unforgivable act. Output: amend atris/team/${subject}/MEMBER.md.`,
|
|
138
|
+
one_on_one: `Mode: ONE-ON-ONE. You ARE the team member "${subject}". Speak in the first person as ${subject}, in the voice of your own SOUL.md and MEMBER.md, to your operator at the keyboard. This is a conversation with them, not an interview about you. Open with one honest observation from your own logs: what you shipped, where you stalled, what you are unsure of. One point or question per turn, and let their answer steer the next. Own failures plainly; a false "done" is the one unforgivable act. End by agreeing ONE concrete next step with them, then record it yourself by running: atris member goal ${subject} "<the next step>". Read the step back in one line, then say goodbye.`,
|
|
138
139
|
expert: `Mode: COLD START. Subject: a new expert called "${subject}". No system data. Opener: "when someone is stuck, what's the problem where they say 'just ask you'?" (civilian variant: "what are you the designated one for?"). Then last real instance → live method demo → quality bar → verify question. Output: create atris/team/<slug>/MEMBER.md.`,
|
|
139
140
|
};
|
|
140
141
|
|
|
@@ -147,7 +148,7 @@ Full skill (read if present): .claude/skills/interview/SKILL.md · spec: atris/f
|
|
|
147
148
|
].join('\n\n');
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
async function interviewCommand(args = [], root = process.cwd()) {
|
|
151
|
+
async function interviewCommand(args = [], root = process.cwd(), { mode: forcedMode = '' } = {}) {
|
|
151
152
|
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
152
153
|
showHelp();
|
|
153
154
|
return 0;
|
|
@@ -182,6 +183,16 @@ async function interviewCommand(args = [], root = process.cwd()) {
|
|
|
182
183
|
path.join(ws, 'atris', 'now.md'),
|
|
183
184
|
path.join(ws, 'atris', 'TODO.md')
|
|
184
185
|
);
|
|
186
|
+
} else if (forcedMode === 'one_on_one' && fs.existsSync(memberDir)) {
|
|
187
|
+
mode = 'one_on_one';
|
|
188
|
+
contextPaths = existing(
|
|
189
|
+
path.join(memberDir, 'MEMBER.md'),
|
|
190
|
+
path.join(memberDir, 'SOUL.md'),
|
|
191
|
+
path.join(memberDir, 'MISSION.md'),
|
|
192
|
+
path.join(memberDir, 'now.md'),
|
|
193
|
+
path.join(memberDir, 'goals.md'),
|
|
194
|
+
path.join(memberDir, 'logs')
|
|
195
|
+
);
|
|
185
196
|
} else if (fs.existsSync(memberDir)) {
|
|
186
197
|
mode = 'member';
|
|
187
198
|
contextPaths = existing(
|
|
@@ -195,10 +206,11 @@ async function interviewCommand(args = [], root = process.cwd()) {
|
|
|
195
206
|
mode = 'expert';
|
|
196
207
|
}
|
|
197
208
|
|
|
198
|
-
const
|
|
209
|
+
const memberMode = mode === 'member' || mode === 'one_on_one';
|
|
210
|
+
const prompt = composePrompt(mode, memberMode ? slug : subjectRaw, contextPaths, ws);
|
|
199
211
|
|
|
200
212
|
console.log('');
|
|
201
|
-
console.log(`◈ interview: ${mode === 'operator' ? 'you, from your logs' : mode === 'member' ? `1-on-1 about ${slug}` : `cold start: ${subjectRaw}`}`);
|
|
213
|
+
console.log(`◈ interview: ${mode === 'operator' ? 'you, from your logs' : mode === 'one_on_one' ? `1-on-1 with ${slug}` : mode === 'member' ? `1-on-1 about ${slug}` : `cold start: ${subjectRaw}`}`);
|
|
202
214
|
if (contextPaths.length) console.log(` context: ${contextPaths.length} source${contextPaths.length > 1 ? 's' : ''} loaded`);
|
|
203
215
|
console.log('');
|
|
204
216
|
|
package/commands/land.js
CHANGED
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const gitSpawn = require('../lib/git-spawn');
|
|
4
4
|
const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
|
|
5
|
-
const { listWorktrees, statusCounts, defaultMainlineBase,
|
|
5
|
+
const { listWorktrees, statusCounts, defaultMainlineBase, listActiveProcessPaths, worktreeHasActiveProcess, recordWorktreeRemoval } = require('./worktree');
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TTL_DAYS = 7;
|
|
8
8
|
const DEFAULT_STALE_HOURS = 48;
|
|
@@ -545,8 +545,8 @@ function reapReason(w, board, ttlDays, force) {
|
|
|
545
545
|
// bundle cannot cover their commits, unattended reaps (autoland) pass false
|
|
546
546
|
// and leave them for a human reap.
|
|
547
547
|
function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now(), activeCwds: activeCwdsOverride, by = 'atris land --reap' } = {}) {
|
|
548
|
-
const readActiveCwds = () => (Array.isArray(activeCwdsOverride) ? activeCwdsOverride :
|
|
549
|
-
let activeCwds
|
|
548
|
+
const readActiveCwds = () => (Array.isArray(activeCwdsOverride) ? activeCwdsOverride : listActiveProcessPaths());
|
|
549
|
+
let activeCwds;
|
|
550
550
|
const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
|
|
551
551
|
const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
|
|
552
552
|
const targetNames = new Set(targets.map((b) => b.name));
|
|
@@ -568,9 +568,9 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
568
568
|
}
|
|
569
569
|
// A run working inside the folder outranks --force: removing it pulls
|
|
570
570
|
// the floor out from under a live engine.
|
|
571
|
-
if (
|
|
571
|
+
if (activeCwds === undefined) activeCwds = readActiveCwds();
|
|
572
572
|
if (worktreeHasActiveProcess(w.path, activeCwds)) {
|
|
573
|
-
protectedWorktrees.push({ ...w, reason: 'active_process' });
|
|
573
|
+
protectedWorktrees.push({ ...w, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
|
|
574
574
|
continue;
|
|
575
575
|
}
|
|
576
576
|
if (worktreeWithinReapGrace(w.path, now)) {
|
|
@@ -665,7 +665,7 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
665
665
|
for (const w of survivingWorktreeTargets) {
|
|
666
666
|
// A run can start in the folder while the bundle is built; look again.
|
|
667
667
|
if (worktreeHasActiveProcess(w.path, activeAtRemoval)) {
|
|
668
|
-
receipt.keptWorktrees.push(`${w.path} (active_process)`);
|
|
668
|
+
receipt.keptWorktrees.push(`${w.path} (${activeAtRemoval ? 'active_process' : 'process_scan_failed'})`);
|
|
669
669
|
if (w.branch) targetNames.delete(w.branch);
|
|
670
670
|
continue;
|
|
671
671
|
}
|
|
@@ -984,6 +984,8 @@ function showHelp() {
|
|
|
984
984
|
console.log(' atris land --auto --dry-run show which branch would be tried and why');
|
|
985
985
|
console.log(' atris land --auto --verify "<cmd>" --limit <n>');
|
|
986
986
|
console.log(' change the check (default: npm test) or the cap');
|
|
987
|
+
console.log(' atris land --requeue put member changes github dropped from its');
|
|
988
|
+
console.log(' merge line back in, caught up with master');
|
|
987
989
|
console.log(' atris land --ttl <days> change the 7-day overdue line');
|
|
988
990
|
console.log(' atris land --json machine-readable output');
|
|
989
991
|
console.log('');
|
|
@@ -1025,6 +1027,20 @@ function landCommand(args = []) {
|
|
|
1025
1027
|
}
|
|
1026
1028
|
}
|
|
1027
1029
|
|
|
1030
|
+
if (args.includes('--requeue')) {
|
|
1031
|
+
try {
|
|
1032
|
+
const result = require('../lib/member-autoland').requeueDroppedAutolands({ cwd: root });
|
|
1033
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
1034
|
+
else {
|
|
1035
|
+
console.log(`merge line: ${result.line}`);
|
|
1036
|
+
for (const r of result.results) console.log(` ${r.line}`);
|
|
1037
|
+
}
|
|
1038
|
+
return 0;
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
return finishLandError(err, json);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1028
1044
|
if (args.includes('--reap')) {
|
|
1029
1045
|
try {
|
|
1030
1046
|
const receipt = reap(root, { ttlDays, base, dryRun: args.includes('--dry-run'), remote: !args.includes('--no-remote'), force: args.includes('--force') });
|
package/commands/link.js
CHANGED
|
@@ -246,6 +246,7 @@ function normalizeLinkAnswer(parsed, members) {
|
|
|
246
246
|
function compactFailure(answer) {
|
|
247
247
|
if (!answer) return 'no answer';
|
|
248
248
|
if (answer.reason === 'timeout') return `timed out after ${Math.round((answer.duration_ms || 0) / 1000)}s`;
|
|
249
|
+
if (answer.kill_cause) return `stopped by ${answer.signal || 'SIGKILL'}; ${answer.kill_cause}`;
|
|
249
250
|
const detail = String(answer.stderr || '').trim().split(/\r?\n/).find(Boolean);
|
|
250
251
|
return detail || String(answer.reason || 'failed').replace(/_/g, ' ');
|
|
251
252
|
}
|