atris 3.38.0 → 3.41.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.
Files changed (78) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +31 -6
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/computer.js +20 -0
  13. package/commands/console.js +19 -3
  14. package/commands/decide.js +166 -0
  15. package/commands/deck.js +1 -4
  16. package/commands/drill.js +14 -24
  17. package/commands/engine.js +196 -8
  18. package/commands/gm.js +8 -6
  19. package/commands/harvest.js +1 -4
  20. package/commands/init.js +23 -3
  21. package/commands/land.js +8 -14
  22. package/commands/launchpad.js +1 -14
  23. package/commands/lifecycle.js +5 -5
  24. package/commands/log.js +55 -5
  25. package/commands/member.js +558 -574
  26. package/commands/mission.js +456 -237
  27. package/commands/pack.js +2746 -164
  28. package/commands/play.js +6 -4
  29. package/commands/probe.js +2 -2
  30. package/commands/pulse.js +15 -16
  31. package/commands/release.js +10 -9
  32. package/commands/router.js +5 -4
  33. package/commands/site-deploy.js +885 -0
  34. package/commands/site.js +11 -2
  35. package/commands/slop.js +14 -2
  36. package/commands/stream.js +4 -18
  37. package/commands/task.js +880 -558
  38. package/commands/taste.js +101 -0
  39. package/commands/team.js +176 -3
  40. package/commands/vercel.js +4 -2
  41. package/commands/voice.js +195 -0
  42. package/commands/watch.js +1 -22
  43. package/commands/wiki.js +1 -4
  44. package/commands/workflow.js +2 -2
  45. package/commands/worktree.js +1 -14
  46. package/commands/xp.js +27 -24
  47. package/lib/accept-verify-gate.js +5 -1
  48. package/lib/arg-parser.js +41 -0
  49. package/lib/auto-accept-certified.js +116 -1
  50. package/lib/autoland.js +66 -0
  51. package/lib/bench/runner.js +19 -1
  52. package/lib/context-gatherer.js +7 -1
  53. package/lib/engine-registry.js +141 -20
  54. package/lib/falsifier-probe.js +84 -0
  55. package/lib/fleet.js +65 -15
  56. package/lib/git-spawn.js +15 -0
  57. package/lib/json-file.js +37 -0
  58. package/lib/known-commands.js +2 -2
  59. package/lib/lesson-preflight.js +146 -0
  60. package/lib/loop-doctor.js +0 -2
  61. package/lib/mission-human-asks.js +28 -0
  62. package/lib/mission-protected-lane.js +4 -1
  63. package/lib/official-cli-integration.js +47 -2
  64. package/lib/orb-context.js +8 -1
  65. package/lib/pack-capabilities.js +685 -0
  66. package/lib/router-brain.js +51 -1
  67. package/lib/runner-command.js +0 -6
  68. package/lib/self-drive.js +44 -13
  69. package/lib/task-db.js +137 -3
  70. package/lib/task-decision.js +50 -0
  71. package/lib/taste-lessons.js +153 -0
  72. package/lib/tool-result-encode.js +17 -1
  73. package/lib/voice-gate.js +66 -0
  74. package/lib/wish-audit.js +1 -1
  75. package/lib/wish-delegate.js +1 -1
  76. package/lib/zip.js +95 -7
  77. package/package.json +2 -1
  78. package/templates/business-starter/persona.md +9 -0
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const { addTaste, listTaste } = require('../lib/taste-lessons');
4
+
5
+ const VALID_FLAGS = new Set(['--why', '--scope', '--example']);
6
+
7
+ function usage(stdout = process.stdout) {
8
+ stdout.write([
9
+ 'usage: atris taste keep|kill|more "<subject>" --why "<reason>" [--scope writing|design|code|any] [--example path]',
10
+ ' atris taste list [--scope writing|design|code|any]',
11
+ '',
12
+ ].join('\n'));
13
+ }
14
+
15
+ function flagValue(args, flag) {
16
+ const index = args.indexOf(flag);
17
+ if (index === -1) return undefined;
18
+ const value = args[index + 1];
19
+ if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value`);
20
+ return value;
21
+ }
22
+
23
+ function rejectUnknownArgs(args, positionalCount) {
24
+ for (let index = positionalCount; index < args.length; index += 1) {
25
+ const value = args[index];
26
+ if (!value.startsWith('--')) continue;
27
+ if (!VALID_FLAGS.has(value)) throw new Error(`unknown option: ${value}`);
28
+ index += 1;
29
+ }
30
+ }
31
+
32
+ function renderTaste(entries, stdout = process.stdout, filtered = false) {
33
+ if (!entries.length) {
34
+ stdout.write(`${filtered ? 'no taste lessons match this scope.' : 'no taste lessons have been recorded yet.'}\n`);
35
+ return;
36
+ }
37
+
38
+ for (const entry of entries) {
39
+ stdout.write(`the operator's verdict is ${entry.verdict} for "${entry.subject}".\n`);
40
+ stdout.write(`the reason is: ${entry.why}\n`);
41
+ stdout.write(`this applies to ${entry.scope}.\n`);
42
+ if (entry.example) stdout.write(`the example is ${entry.example}.\n`);
43
+ stdout.write(`this was added on ${entry.added}.\n\n`);
44
+ }
45
+ }
46
+
47
+ function tasteCommand(args = [], options = {}) {
48
+ const stdout = options.stdout || process.stdout;
49
+ const stderr = options.stderr || process.stderr;
50
+ const root = options.root || process.cwd();
51
+ const [subcommand, ...rest] = args;
52
+
53
+ if (!subcommand) {
54
+ renderTaste(listTaste({ root }), stdout);
55
+ stdout.write('\n');
56
+ usage(stdout);
57
+ return 0;
58
+ }
59
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
60
+ usage(stdout);
61
+ return 0;
62
+ }
63
+
64
+ try {
65
+ if (subcommand === 'list') {
66
+ rejectUnknownArgs(rest, 0);
67
+ const scope = flagValue(rest, '--scope');
68
+ renderTaste(listTaste({ root, scope }), stdout, !!scope);
69
+ return 0;
70
+ }
71
+
72
+ if (!['keep', 'kill', 'more'].includes(subcommand)) {
73
+ stderr.write(`unknown taste verdict: ${subcommand}.\n`);
74
+ usage(stderr);
75
+ return 2;
76
+ }
77
+
78
+ rejectUnknownArgs(rest, 1);
79
+ const subject = rest[0];
80
+ const why = flagValue(rest, '--why');
81
+ const scope = flagValue(rest, '--scope') || 'any';
82
+ const example = flagValue(rest, '--example');
83
+ const entry = addTaste({
84
+ verdict: subcommand,
85
+ subject,
86
+ why,
87
+ scope,
88
+ example,
89
+ added: new Date().toISOString().slice(0, 10),
90
+ root,
91
+ });
92
+ stdout.write(`saved the operator's ${entry.verdict} verdict for "${entry.subject}".\n`);
93
+ return 0;
94
+ } catch (error) {
95
+ stderr.write(`taste could not continue: ${error.message}.\n`);
96
+ usage(stderr);
97
+ return 2;
98
+ }
99
+ }
100
+
101
+ module.exports = { tasteCommand };
package/commands/team.js CHANGED
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { canonicalEngineName } = require('../lib/engine-registry');
3
7
  const taskDb = require('../lib/task-db');
4
8
  const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
5
9
  const { listMissions, listWorktreeRollupMissions } = require('./mission');
@@ -45,11 +49,150 @@ function collectTeamPresence(deps = {}) {
45
49
  });
46
50
  }
47
51
 
52
+ // One team view: member folders under atris/team/ are the who, live missions
53
+ // are the what-they-run-on. The fleet keeps no state file, so an engine
54
+ // "assignment" is read straight from missions still in flight.
55
+ const ROSTER_LIVE_MISSION_STATUSES = new Set(['running', 'planning']);
56
+
57
+ function collectMembers(root, deps = {}) {
58
+ if (Array.isArray(deps.members)) return deps.members;
59
+ const memberModule = deps.memberModule || require('./member');
60
+ return memberModule.findAllMembers(path.join(root, 'atris', 'team'));
61
+ }
62
+
63
+ // The MEMBER.md role line, made safe for a human sentence: lowercase, no em
64
+ // dashes, no repeated name prefix ("Linguist - operator language" -> "operator
65
+ // language" when the member is already named on the line).
66
+ function plainRole(member) {
67
+ // findAllMembers stamps '(no role)' on members without a role line; treat
68
+ // that placeholder as empty so the description can fill in.
69
+ const role = String(member?.role || '').trim();
70
+ const raw = (role && role !== '(no role)' ? role : String(member?.description || '')).trim();
71
+ const cleaned = raw
72
+ .replace(/[\u2013\u2014]/g, '-')
73
+ .replace(/\s+/g, ' ')
74
+ .toLowerCase()
75
+ .replace(/[.\s]+$/, '');
76
+ const name = String(member?.name || '').trim().toLowerCase();
77
+ const deduped = name && cleaned.startsWith(name)
78
+ ? cleaned.slice(name.length).replace(/^[\s:,-]+/, '')
79
+ : cleaned;
80
+ if (!deduped) return 'no role written yet';
81
+ // Keep the sentence readable: descriptions can run long, roles never should.
82
+ if (deduped.length <= 100) return deduped;
83
+ const cut = deduped.slice(0, 100);
84
+ return `${cut.slice(0, cut.lastIndexOf(' '))}`.replace(/[,;:]+$/, '');
85
+ }
86
+
87
+ function missionEngine(mission) {
88
+ return canonicalEngineName(mission?.runner) || canonicalEngineName(mission?.engine);
89
+ }
90
+
91
+ function collectTeamRoster(deps = {}) {
92
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
93
+ const engineByOwner = new Map();
94
+ for (const mission of collectMissions(root, deps)) {
95
+ if (!ROSTER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
96
+ const owner = String(mission?.owner || mission?.member || '').trim().toLowerCase();
97
+ const engine = missionEngine(mission);
98
+ // Missions arrive newest-first; the first live one per owner wins.
99
+ if (owner && engine && !engineByOwner.has(owner)) engineByOwner.set(owner, engine);
100
+ }
101
+ return collectMembers(root, deps)
102
+ .map((member) => {
103
+ const name = String(member?.name || '').trim().toLowerCase();
104
+ return { name, role: plainRole(member), engine: engineByOwner.get(name) || '' };
105
+ })
106
+ .filter((entry) => entry.name)
107
+ .sort((a, b) => a.name.localeCompare(b.name));
108
+ }
109
+
110
+ function renderTeamRoster(roster) {
111
+ if (!roster.length) {
112
+ return 'no team members yet. create one with: atris member create <name> --role="..."';
113
+ }
114
+ return roster
115
+ .map((entry) => `${entry.name} - ${entry.role}${entry.engine ? `, on ${entry.engine}` : ''}.`)
116
+ .join('\n');
117
+ }
118
+
119
+ // The pruning pass keeps the team lean like a real company: it flags members
120
+ // with no recent signal, and it never deletes anything. A signal is the newest
121
+ // of MEMBER.md, any logs/*.md, or a mission the member owns that is still
122
+ // active or running.
123
+ const PRUNE_ACTIVE_MISSION_STATUSES = new Set(['active', 'running']);
124
+ const DEFAULT_PRUNE_DAYS = 30;
125
+ const DAY_MS = 24 * 60 * 60 * 1000;
126
+
127
+ function newestSignalMs(member) {
128
+ const times = [];
129
+ const stamp = (file) => {
130
+ try { times.push(fs.statSync(file).mtimeMs); } catch { /* missing file is just no signal */ }
131
+ };
132
+ if (member?.path) stamp(member.path);
133
+ if (member?.dir) {
134
+ const logsDir = path.join(member.dir, 'logs');
135
+ let entries = [];
136
+ try { entries = fs.readdirSync(logsDir); } catch { entries = []; }
137
+ for (const entry of entries) {
138
+ if (entry.endsWith('.md')) stamp(path.join(logsDir, entry));
139
+ }
140
+ }
141
+ return times.length ? Math.max(...times) : 0;
142
+ }
143
+
144
+ function collectTeamPrune(deps = {}) {
145
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
146
+ const days = Number.isFinite(deps.days) && deps.days > 0 ? deps.days : DEFAULT_PRUNE_DAYS;
147
+ const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
148
+ const activeOwners = new Set();
149
+ for (const mission of collectMissions(root, deps)) {
150
+ if (!PRUNE_ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
151
+ const owner = String(mission?.owner || mission?.member || '').trim().toLowerCase();
152
+ if (owner) activeOwners.add(owner);
153
+ }
154
+ const quiet = [];
155
+ let activeCount = 0;
156
+ for (const member of collectMembers(root, deps)) {
157
+ const name = String(member?.name || '').trim().toLowerCase();
158
+ if (!name) continue;
159
+ const signalMs = newestSignalMs(member);
160
+ if (activeOwners.has(name) || (signalMs && nowMs - signalMs < days * DAY_MS)) {
161
+ activeCount += 1;
162
+ continue;
163
+ }
164
+ quiet.push({
165
+ name,
166
+ days_quiet: signalMs ? Math.floor((nowMs - signalMs) / DAY_MS) : null,
167
+ last_signal: signalMs ? new Date(signalMs).toISOString() : null,
168
+ });
169
+ }
170
+ quiet.sort((a, b) => a.name.localeCompare(b.name));
171
+ return { quiet, active_count: activeCount };
172
+ }
173
+
174
+ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
175
+ if (!report.quiet.length && !report.active_count) {
176
+ return 'no team members yet. create one with: atris member create <name> --role="..."';
177
+ }
178
+ if (!report.quiet.length) {
179
+ return `everyone on the team has a signal newer than ${days} days. nothing to prune.`;
180
+ }
181
+ const lines = report.quiet.map((entry) => (entry.days_quiet === null
182
+ ? `${entry.name} has no recorded activity; keep, hand off, or retire.`
183
+ : `${entry.name} has been quiet for ${entry.days_quiet} days; keep, hand off, or retire.`));
184
+ lines.push(`${report.active_count} member${report.active_count === 1 ? ' is' : 's are'} still active. nothing was deleted; this is a report.`);
185
+ return lines.join('\n');
186
+ }
187
+
48
188
  function helpText() {
49
189
  return [
190
+ 'atris team - one team view: every member, their role, and any engine running their work',
50
191
  'atris team presence - show who is awake and what they are doing',
192
+ 'atris team prune - flag members with no recent activity; deletes nothing',
51
193
  '',
52
- 'usage: atris team presence [--json]',
194
+ 'usage: atris team [roster|presence] [--json]',
195
+ 'usage: atris team prune [--days N] [--json]',
53
196
  ].join('\n');
54
197
  }
55
198
 
@@ -58,8 +201,38 @@ function teamCommand(args = [], deps = {}) {
58
201
  (deps.write || process.stdout.write.bind(process.stdout))(`${helpText()}\n`);
59
202
  return 0;
60
203
  }
204
+ if (args[0] === 'prune') {
205
+ const rest = args.slice(1);
206
+ let days = DEFAULT_PRUNE_DAYS;
207
+ let json = false;
208
+ let bad = false;
209
+ for (let i = 0; i < rest.length; i += 1) {
210
+ const arg = rest[i];
211
+ if (arg === '--json') { json = true; continue; }
212
+ if (arg === '--days') { i += 1; days = Number(rest[i]); continue; }
213
+ if (arg.startsWith('--days=')) { days = Number(arg.slice('--days='.length)); continue; }
214
+ bad = true;
215
+ }
216
+ if (bad || !Number.isFinite(days) || days <= 0) {
217
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team prune [--days N] [--json]\n');
218
+ return 2;
219
+ }
220
+ const report = deps.prune || collectTeamPrune({ ...deps, days });
221
+ const output = json ? JSON.stringify(report, null, 2) : renderTeamPrune(report, days);
222
+ (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
223
+ return 0;
224
+ }
225
+ const rosterArgs = args.filter((arg) => arg !== 'roster');
226
+ if (args[0] !== 'presence' && rosterArgs.every((arg) => arg === '--json')) {
227
+ const roster = deps.roster || collectTeamRoster(deps);
228
+ const output = rosterArgs.includes('--json')
229
+ ? JSON.stringify(roster, null, 2)
230
+ : renderTeamRoster(roster);
231
+ (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
232
+ return 0;
233
+ }
61
234
  if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
62
- (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team presence [--json]\n');
235
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json]\n');
63
236
  return 2;
64
237
  }
65
238
  const presence = deps.presence || collectTeamPresence(deps);
@@ -70,4 +243,4 @@ function teamCommand(args = [], deps = {}) {
70
243
  return 0;
71
244
  }
72
245
 
73
- module.exports = { collectMissions, collectTasks, collectTeamPresence, teamCommand };
246
+ module.exports = { collectMissions, collectTasks, collectTeamPresence, collectTeamPrune, collectTeamRoster, renderTeamPrune, renderTeamRoster, teamCommand };
@@ -12,7 +12,8 @@ const vercelCommand = createOfficialCliCommand({
12
12
  usage: 'deploy',
13
13
  match: ['deploy'],
14
14
  forward: ['deploy'],
15
- description: 'deploy the current project',
15
+ description: 'deploy the current project (grouped output)',
16
+ summarize: true,
16
17
  },
17
18
  {
18
19
  usage: 'ls',
@@ -24,7 +25,8 @@ const vercelCommand = createOfficialCliCommand({
24
25
  usage: 'logs',
25
26
  match: ['logs'],
26
27
  forward: ['logs'],
27
- description: 'stream or inspect deployment logs',
28
+ description: 'stream or inspect deployment logs (grouped output)',
29
+ summarize: true,
28
30
  },
29
31
  {
30
32
  usage: 'inspect',
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const deterministicVoice = require('../scripts/det/voice');
9
+
10
+ const JUDGE_TIMEOUT_MS = 30_000;
11
+
12
+ function extractVoiceSection(repoRoot = process.cwd()) {
13
+ const candidates = [
14
+ path.join(repoRoot, 'atris.md'),
15
+ path.join(repoRoot, 'atris', 'atris.md'),
16
+ ];
17
+
18
+ for (const file of candidates) {
19
+ if (!fs.existsSync(file)) continue;
20
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
21
+ const start = lines.findIndex((line) => /^## voice\s*$/i.test(line));
22
+ if (start === -1) continue;
23
+ let end = lines.length;
24
+ for (let i = start + 1; i < lines.length; i += 1) {
25
+ if (/^##\s+/.test(lines[i])) {
26
+ end = i;
27
+ break;
28
+ }
29
+ }
30
+ return lines.slice(start, end).join('\n').trim();
31
+ }
32
+
33
+ throw new Error('the voice section was not found');
34
+ }
35
+
36
+ function findExecutable(name, env = process.env) {
37
+ const pathValue = env.PATH || '';
38
+ for (const dir of pathValue.split(path.delimiter)) {
39
+ if (!dir) continue;
40
+ const candidate = path.join(dir, name);
41
+ try {
42
+ fs.accessSync(candidate, fs.constants.X_OK);
43
+ return candidate;
44
+ } catch {
45
+ // Try the next directory.
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function judgePrompt(rubric) {
52
+ return [
53
+ 'Judge only the SHAPE and PLAINNESS of the reply provided on stdin against the rubric below.',
54
+ 'Do not judge factual correctness or technical quality.',
55
+ 'When unsure, PASS. False alarms are worse than misses.',
56
+ 'Return only strict JSON with exactly this shape: {"pass": boolean, "reasons": [strings]}.',
57
+ '',
58
+ rubric,
59
+ ].join('\n');
60
+ }
61
+
62
+ function parseVerdict(output) {
63
+ const verdict = JSON.parse(String(output || '').trim());
64
+ if (!verdict || Array.isArray(verdict) || typeof verdict !== 'object') {
65
+ throw new Error('the verdict is not an object');
66
+ }
67
+ const keys = Object.keys(verdict).sort();
68
+ if (keys.length !== 2 || keys[0] !== 'pass' || keys[1] !== 'reasons') {
69
+ throw new Error('the verdict has the wrong fields');
70
+ }
71
+ if (typeof verdict.pass !== 'boolean'
72
+ || !Array.isArray(verdict.reasons)
73
+ || !verdict.reasons.every((reason) => typeof reason === 'string')) {
74
+ throw new Error('the verdict has the wrong shape');
75
+ }
76
+ return verdict;
77
+ }
78
+
79
+ function unavailable(stderr, reason) {
80
+ const cleanReason = String(reason || 'unknown error').replace(/\s+/g, ' ').trim();
81
+ stderr.write(`voice judge unavailable: ${cleanReason}\n`);
82
+ return 0;
83
+ }
84
+
85
+ function runScan(reply, args, stdout) {
86
+ const mode = args.includes('--json') ? 'json' : 'scan';
87
+ const result = deterministicVoice.run(mode, reply);
88
+ if (result.error) return { code: 2, error: result.error };
89
+ stdout.write(`${result.text}\n`);
90
+ const passed = result.text.startsWith('PASS') || result.text.startsWith('{"pass":true');
91
+ return { code: passed ? 0 : 1 };
92
+ }
93
+
94
+ function runJudge(reply, options = {}) {
95
+ const repoRoot = options.repoRoot || process.cwd();
96
+ const env = options.env || process.env;
97
+ const stdout = options.stdout || process.stdout;
98
+ const stderr = options.stderr || process.stderr;
99
+
100
+ let rubric;
101
+ try {
102
+ rubric = extractVoiceSection(repoRoot);
103
+ } catch (error) {
104
+ return unavailable(stderr, error.message);
105
+ }
106
+
107
+ const customCommand = String(env.ATRIS_VOICE_JUDGE_CMD || '').trim();
108
+ const claudePath = customCommand ? null : findExecutable('claude', env);
109
+ if (!customCommand && !claudePath) {
110
+ return unavailable(stderr, 'no judge command was found');
111
+ }
112
+
113
+ let tempDir;
114
+ try {
115
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-voice-'));
116
+ const rubricPath = path.join(tempDir, 'rubric.md');
117
+ fs.writeFileSync(rubricPath, rubric, 'utf8');
118
+ const childEnv = { ...env, ATRIS_VOICE_RUBRIC: rubricPath };
119
+ const result = customCommand
120
+ ? spawnSync('/bin/sh', ['-c', customCommand], {
121
+ input: reply,
122
+ encoding: 'utf8',
123
+ env: childEnv,
124
+ timeout: JUDGE_TIMEOUT_MS,
125
+ maxBuffer: 1024 * 1024,
126
+ })
127
+ : spawnSync(claudePath, ['-p', '--model', 'haiku', judgePrompt(rubric)], {
128
+ input: reply,
129
+ encoding: 'utf8',
130
+ env: childEnv,
131
+ timeout: JUDGE_TIMEOUT_MS,
132
+ maxBuffer: 1024 * 1024,
133
+ });
134
+
135
+ if (result.error) return unavailable(stderr, result.error.code === 'ETIMEDOUT' ? 'the judge timed out' : 'the judge command failed');
136
+ if (result.status !== 0) return unavailable(stderr, 'the judge command failed');
137
+
138
+ let verdict;
139
+ try {
140
+ verdict = parseVerdict(result.stdout);
141
+ } catch {
142
+ return unavailable(stderr, 'the judge returned an unreadable verdict');
143
+ }
144
+
145
+ stdout.write(`${JSON.stringify(verdict)}\n`);
146
+ return verdict.pass ? 0 : 1;
147
+ } catch {
148
+ return unavailable(stderr, 'the judge command failed');
149
+ } finally {
150
+ if (tempDir) {
151
+ try {
152
+ fs.rmSync(tempDir, { recursive: true, force: true });
153
+ } catch {
154
+ // Cleanup cannot block the reply.
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ function showVoiceHelp(stdout = process.stdout) {
161
+ stdout.write([
162
+ 'usage: atris voice scan [--json]',
163
+ ' atris voice judge',
164
+ '',
165
+ 'scan checks binary voice tells. judge checks reply shape and plainness.',
166
+ '',
167
+ ].join('\n'));
168
+ }
169
+
170
+ function voiceCommand(args, options = {}) {
171
+ const subcommand = args[0];
172
+ const stdout = options.stdout || process.stdout;
173
+ const stderr = options.stderr || process.stderr;
174
+ if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
175
+ showVoiceHelp(stdout);
176
+ return 0;
177
+ }
178
+ if (!['scan', 'judge'].includes(subcommand)) {
179
+ stderr.write('usage: atris voice scan [--json] | atris voice judge\n');
180
+ return 2;
181
+ }
182
+
183
+ const reply = options.input === undefined ? fs.readFileSync(0, 'utf8') : options.input;
184
+ if (subcommand === 'scan') {
185
+ const result = runScan(reply, args.slice(1), stdout);
186
+ if (result.error) stderr.write(`${result.error}\n`);
187
+ return result.code;
188
+ }
189
+ return runJudge(reply, { ...options, stdout, stderr });
190
+ }
191
+
192
+ module.exports = {
193
+ extractVoiceSection,
194
+ voiceCommand,
195
+ };
package/commands/watch.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
+ const { hasFlag, readFlag } = require('../lib/arg-parser');
6
7
  const { startMission, listMissions } = require('./mission');
7
8
 
8
9
  const CLI_PATH = path.join(__dirname, '..', 'bin', 'atris.js');
@@ -22,28 +23,6 @@ function slugify(value) {
22
23
  .slice(0, 48) || 'watch';
23
24
  }
24
25
 
25
- function hasFlag(args, name) {
26
- return args.includes(name);
27
- }
28
-
29
- function unquote(value) {
30
- const text = String(value);
31
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
32
- return text.slice(1, -1);
33
- }
34
- return text;
35
- }
36
-
37
- function readFlag(args, name, fallback = '') {
38
- const prefix = `${name}=`;
39
- for (let i = 0; i < args.length; i += 1) {
40
- const arg = String(args[i]);
41
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return unquote(args[i + 1]);
42
- if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
43
- }
44
- return fallback;
45
- }
46
-
47
26
  function parseSentenceArgs(args) {
48
27
  const sentenceParts = [];
49
28
  let index = 0;
package/commands/wiki.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { hasFlag } = require('../lib/arg-parser');
3
4
  const { loadCredentials } = require('../utils/auth');
4
5
  const { apiRequestJson } = require('../utils/api');
5
6
  const { loadBusinesses, saveBusinesses } = require('./business');
@@ -223,10 +224,6 @@ function printWikiHelp(scope = null) {
223
224
  console.log('');
224
225
  }
225
226
 
226
- function hasFlag(args, name) {
227
- return args.includes(name);
228
- }
229
-
230
227
  function optionValue(args, name, fallback = null) {
231
228
  const index = args.indexOf(name);
232
229
  if (index === -1 || index + 1 >= args.length) return fallback;
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { getLogPath } = require('../lib/journal');
4
- const { encodeToolResult } = require('../lib/tool-result-encode');
4
+ const { buildToolResultBody } = require('../lib/tool-result-encode');
5
5
 
6
6
  function wrapWorkflowText(text, width = 76) {
7
7
  const normalized = String(text || '').replace(/\s+/g, ' ').trim();
@@ -140,7 +140,7 @@ function postToolResult(callId, result, base = 'http://127.0.0.1:8000') {
140
140
  const url = new URL('/api/atris2/turn/tool-result', base);
141
141
  const transport = url.protocol === 'https:' ? require('https') : require('http');
142
142
  return new Promise((resolve, reject) => {
143
- const postData = JSON.stringify({ call_id: callId, result_base64: encodeToolResult(result) });
143
+ const postData = JSON.stringify(buildToolResultBody(callId, result));
144
144
  const req = transport.request({
145
145
  hostname: url.hostname,
146
146
  port: url.port || (url.protocol === 'https:' ? 443 : 80),
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
+ const { hasFlag, readFlag } = require('../lib/arg-parser');
6
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
7
8
  const { isConductorArtifact } = require('../lib/conductor-artifacts');
8
9
 
@@ -321,20 +322,6 @@ function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {})
321
322
  return skipped;
322
323
  }
323
324
 
324
- function readFlag(args, name, fallback = '') {
325
- const prefix = `${name}=`;
326
- for (let i = 0; i < args.length; i += 1) {
327
- const arg = String(args[i]);
328
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return String(args[i + 1]);
329
- if (arg.startsWith(prefix)) return arg.slice(prefix.length);
330
- }
331
- return fallback;
332
- }
333
-
334
- function hasFlag(args, name) {
335
- return args.includes(name);
336
- }
337
-
338
325
  function swarloClaim(root, { channel, taskKey, content }) {
339
326
  const script = path.join(root, 'scripts', 'swarlo.py');
340
327
  if (!fs.existsSync(script)) return 'skip: scripts/swarlo.py not found';