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
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { spawnSync } = require('child_process');
5
+ const childProcess = require('child_process');
6
6
  const {
7
7
  RUNNER_PROFILE_DEFS,
8
8
  RUNNER_PROFILE_ALIASES,
@@ -13,30 +13,33 @@ const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain')
13
13
  const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
14
14
  const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
15
15
  const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
16
+ const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
16
17
  const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
17
18
 
18
19
  const ENGINE_SEED_META = Object.freeze({
19
- 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), fallback_order: 10 }),
20
- codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 10 }),
21
- claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 20 }),
22
- cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 30 }),
23
- devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), fallback_order: 40 }),
24
- grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 45 }),
25
- fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 50 }),
26
- composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), fallback_order: 60 }),
27
- haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), fallback_order: 70 }),
28
- hermes: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 80 }),
29
- droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 90 }),
20
+ 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
21
+ codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['codex']), fallback_order: 10 }),
22
+ claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), fallback_order: 20 }),
23
+ cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.5', 'kimi 3']), fallback_order: 30 }),
24
+ devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
25
+ grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.5']), fallback_order: 45 }),
26
+ fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
27
+ composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
28
+ haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
29
+ droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 90 }),
30
30
  });
31
31
 
32
32
  function engineRegistryFile(root = process.cwd()) {
33
33
  return path.join(root, '.atris', 'state', 'engines.json');
34
34
  }
35
35
 
36
+ // Machine probe. Routing never calls this on a settled registry: it runs once
37
+ // when an engine first appears (seeding the policy file), at the execution
38
+ // stage right before a spawn, and on the explicit `atris engine doctor`.
36
39
  function binInstalled(bin) {
37
40
  const safe = String(bin || '').replace(/[^A-Za-z0-9_.-]/g, '');
38
41
  if (!safe) return false;
39
- const probe = spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
42
+ const probe = childProcess.spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
40
43
  return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
41
44
  }
42
45
 
@@ -68,6 +71,18 @@ function normalizeRoles(value, fallback = ['executor']) {
68
71
  return filtered.length ? filtered : fallback;
69
72
  }
70
73
 
74
+ function normalizeModels(value, fallback = []) {
75
+ const models = Array.isArray(value) ? value.map((model) => String(model || '').trim()) : [];
76
+ const filtered = models.filter((model, index) => model && models.indexOf(model) === index);
77
+ return filtered.length ? filtered : fallback;
78
+ }
79
+
80
+ function normalizeDuty(value, fallback = '') {
81
+ if (value === undefined) return fallback || '';
82
+ const duty = String(value || '').trim();
83
+ return ENGINE_DUTIES.includes(duty) ? duty : '';
84
+ }
85
+
71
86
  function normalizeFallbackOrder(value, fallback) {
72
87
  const order = Number(value);
73
88
  return Number.isInteger(order) ? order : fallback;
@@ -75,22 +90,32 @@ function normalizeFallbackOrder(value, fallback) {
75
90
 
76
91
  function normalizeEngineEntry(id, saved = {}) {
77
92
  const def = RUNNER_PROFILE_DEFS[id];
78
- const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], fallback_order: 100 };
79
- const installed = binInstalled(def.bin);
93
+ const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], models: [id], fallback_order: 100 };
80
94
  const savedHealth = saved && saved.health && typeof saved.health === 'object' ? saved.health : {};
81
95
  const savedStatus = String(savedHealth.status || '').trim();
82
- const savedFailure = savedStatus === 'credit_out'
83
- || savedStatus === 'error'
84
- || (savedStatus === 'not_installed' && Boolean(savedHealth.last_failure_ts));
85
- const status = savedFailure ? savedStatus : (installed ? 'ready' : 'not_installed');
96
+ // Policy over probes: a saved health status is the routing truth, verbatim.
97
+ // Only an engine the registry has never seen gets one seeding probe; after
98
+ // that, installed-state changes flow through `atris engine doctor` (or
99
+ // `atris engine health`), never through the resolve path.
100
+ let status;
101
+ let installed;
102
+ if (ENGINE_HEALTH_STATUSES.includes(savedStatus)) {
103
+ status = savedStatus;
104
+ installed = typeof saved.installed === 'boolean' ? saved.installed : status !== 'not_installed';
105
+ } else {
106
+ installed = binInstalled(def.bin);
107
+ status = installed ? 'ready' : 'not_installed';
108
+ }
86
109
  const health = { status };
87
- if (savedFailure && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
110
+ if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
88
111
  return {
89
112
  id,
90
113
  name: id,
91
114
  bin: def.bin,
92
115
  tier: normalizeTier(saved.tier, seed.tier),
93
116
  roles: normalizeRoles(saved.roles, Array.from(seed.roles)),
117
+ models: normalizeModels(saved.models, Array.from(seed.models)),
118
+ duty: normalizeDuty(saved.duty, seed.duty),
94
119
  fallback_order: normalizeFallbackOrder(saved.fallback_order, seed.fallback_order),
95
120
  installed,
96
121
  health,
@@ -118,6 +143,62 @@ function writeEngineRegistry(root, registry) {
118
143
  fs.writeFileSync(file, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
119
144
  }
120
145
 
146
+ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
147
+ const id = canonicalEngineName(name);
148
+ const knownIds = RUNNER_PROFILE_NAMES.filter((engineId) => ENGINE_SEED_META[engineId]);
149
+ if (!id || !knownIds.includes(id)) {
150
+ throw new Error(`Unknown engine "${name}". Known engines: ${knownIds.join(', ')}`);
151
+ }
152
+
153
+ const nextOverrides = {};
154
+ if (Object.prototype.hasOwnProperty.call(overrides, 'duty')) {
155
+ const duty = String(overrides.duty || '').trim();
156
+ if (!ENGINE_DUTIES.includes(duty)) {
157
+ throw new Error(`Unknown duty "${overrides.duty}". Known duties: ${ENGINE_DUTIES.join(', ')}`);
158
+ }
159
+ nextOverrides.duty = duty;
160
+ }
161
+ if (Object.prototype.hasOwnProperty.call(overrides, 'models')) {
162
+ const models = normalizeModels(overrides.models, []);
163
+ if (!models.length) throw new Error('models must include at least one name');
164
+ nextOverrides.models = models;
165
+ }
166
+ if (!Object.keys(nextOverrides).length) throw new Error('set requires --duty or --models');
167
+
168
+ const file = engineRegistryFile(root);
169
+ const raw = readRawRegistry(file);
170
+ const savedById = new Map();
171
+ for (const entry of raw.engines || []) {
172
+ const savedId = canonicalEngineName(entry && (entry.id || entry.name));
173
+ if (savedId && knownIds.includes(savedId)) savedById.set(savedId, entry);
174
+ }
175
+
176
+ if (nextOverrides.duty === 'leader' || nextOverrides.duty === 'learning') {
177
+ for (const engineId of knownIds) {
178
+ if (engineId === id) continue;
179
+ const saved = savedById.get(engineId) || {};
180
+ const effectiveDuty = Object.prototype.hasOwnProperty.call(saved, 'duty')
181
+ ? normalizeDuty(saved.duty, '')
182
+ : normalizeDuty(undefined, ENGINE_SEED_META[engineId].duty);
183
+ if (effectiveDuty === nextOverrides.duty) {
184
+ savedById.set(engineId, { ...saved, id: engineId, name: engineId, duty: '' });
185
+ }
186
+ }
187
+ }
188
+
189
+ const saved = savedById.get(id) || {};
190
+ savedById.set(id, { ...saved, id, name: id, ...nextOverrides });
191
+ const ordered = knownIds.map((engineId) => savedById.get(engineId)).filter(Boolean);
192
+ const next = {
193
+ ...raw,
194
+ schema: ENGINE_REGISTRY_SCHEMA,
195
+ updated_at: new Date().toISOString(),
196
+ engines: ordered,
197
+ };
198
+ writeEngineRegistry(root, next);
199
+ return { id, ...nextOverrides };
200
+ }
201
+
121
202
  function readEngineRegistry(root = process.cwd(), options = {}) {
122
203
  const registry = seededRegistry(root);
123
204
  if (options.persist !== false) writeEngineRegistry(root, registry);
@@ -159,6 +240,8 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
159
240
  const ranked = rankEnginesDetailed(engines, {
160
241
  root,
161
242
  taskType: options.taskType || options.task_type || normalizedRole,
243
+ lowStakes: options.lowStakes,
244
+ stakes: options.stakes,
162
245
  });
163
246
  return {
164
247
  engine: ranked.candidates[0] || null,
@@ -224,17 +307,55 @@ function setEngineHealth(name, status, root = process.cwd()) {
224
307
  return engines.find((engine) => engine.id === id);
225
308
  }
226
309
 
310
+ // Execution-stage guard. Routing hands out engines from policy without ever
311
+ // touching the machine, so the moment we are about to spawn one is where a
312
+ // missing binary has to fail loudly, in one plain sentence naming the binary.
313
+ function requireEngineBin(engineOrId) {
314
+ const id = typeof engineOrId === 'string'
315
+ ? canonicalEngineName(engineOrId)
316
+ : canonicalEngineName(engineOrId && engineOrId.id);
317
+ const def = RUNNER_PROFILE_DEFS[id];
318
+ if (!def) {
319
+ throw new Error(`Unknown engine "${engineOrId && engineOrId.id ? engineOrId.id : engineOrId}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
320
+ }
321
+ if (!binInstalled(def.bin)) {
322
+ throw new Error(`${id} CLI (${def.bin}) is not installed here, so this run cannot start.`);
323
+ }
324
+ return def.bin;
325
+ }
326
+
327
+ // The explicit opt-in probe pass: check every engine binary on this machine,
328
+ // fold the result back into the policy file (a ready/not_installed flip only;
329
+ // credit_out and error are operator policy and survive), and report.
330
+ function engineDoctorReport(root = process.cwd()) {
331
+ const registry = readEngineRegistry(root, { persist: false });
332
+ const engines = registry.engines.map((engine) => {
333
+ const installed = binInstalled(engine.bin);
334
+ let health = engine.health && engine.health.status ? engine.health : { status: installed ? 'ready' : 'not_installed' };
335
+ if (installed && health.status === 'not_installed') health = { status: 'ready' };
336
+ if (!installed && health.status === 'ready') health = { status: 'not_installed' };
337
+ return { ...engine, installed, health };
338
+ });
339
+ const next = { ...registry, updated_at: new Date().toISOString(), engines };
340
+ writeEngineRegistry(root, next);
341
+ return engines;
342
+ }
343
+
227
344
  module.exports = {
228
345
  ENGINE_ROLES,
346
+ ENGINE_DUTIES,
229
347
  ENGINE_HEALTH_STATUSES,
230
348
  engineRegistryFile,
231
349
  binInstalled,
232
350
  canonicalEngineName,
233
351
  readEngineRegistry,
352
+ requireEngineBin,
353
+ engineDoctorReport,
234
354
  engineRegistryView,
235
355
  resolveRegisteredEngine,
236
356
  resolveEngineForRoleRanked,
237
357
  resolveEngineForRole,
238
358
  resolveEngineForRoleWithPreference,
359
+ setEngineOverrides,
239
360
  setEngineHealth,
240
361
  };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ // A check that cannot fail is not a check. `task ready --verify` runs a command
4
+ // and accepts the work on exit 0, which catches a command that fails but never
5
+ // catches one that could not have failed. The 2026-07-26 reward-ledger audit
6
+ // found 131 of 802 accepted proofs carrying nothing falsifiable.
7
+ //
8
+ // The probe: run the same command again in an empty directory, with none of
9
+ // the work present. A check anchored to this codebase fails there. One that
10
+ // passes anywhere - `true`, `echo done`, a check of something outside the
11
+ // repo - passes there too, and proves nothing about the work.
12
+ //
13
+ // Scope, stated plainly: this asks whether a check depends on the codebase at
14
+ // all. It does not ask whether the check exercises the change. `git diff
15
+ // --check` fails in an empty directory and still says nothing about whether
16
+ // the code works, so it clears this probe. The probe raises the floor; it is
17
+ // not a ceiling.
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const PROBE_TIMEOUT_MS = 60_000;
25
+
26
+ // An absolute path reaches back into a real checkout no matter where the
27
+ // command runs, so the empty directory proves nothing about it. Say so rather
28
+ // than reporting a false verdict.
29
+ function absolutePathIn(command) {
30
+ const match = String(command || '').match(/(?:^|[\s"'`=(])(\/[^\s"'`)]+)/);
31
+ return match ? match[1] : '';
32
+ }
33
+
34
+ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
35
+ const cmd = String(command || '').trim();
36
+ if (!cmd) return { probed: false, canFail: null, reason: 'no command to probe' };
37
+
38
+ const absolute = absolutePathIn(cmd);
39
+ if (absolute) {
40
+ return {
41
+ probed: false,
42
+ canFail: null,
43
+ reason: `not probed: the command reaches an absolute path (${absolute}), so running it away from this checkout proves nothing`,
44
+ };
45
+ }
46
+
47
+ const dir = fs.mkdtempSync(path.join(tmpRoot || os.tmpdir(), 'atris-falsifier-probe-'));
48
+ try {
49
+ const run = runner || spawnSync;
50
+ const result = run('bash', ['-lc', cmd], {
51
+ cwd: dir,
52
+ encoding: 'utf8',
53
+ timeout: PROBE_TIMEOUT_MS,
54
+ });
55
+ if (result.error) {
56
+ const timedOut = /ETIMEDOUT/i.test(String(result.error.code || result.error.message || ''));
57
+ return {
58
+ probed: false,
59
+ canFail: null,
60
+ reason: timedOut
61
+ ? `not probed: the command ran past ${PROBE_TIMEOUT_MS / 1000}s with none of the work present`
62
+ : `not probed: the command could not start away from this checkout (${result.error.message})`,
63
+ };
64
+ }
65
+ if (result.status === 0) {
66
+ return {
67
+ probed: true,
68
+ canFail: false,
69
+ exit: 0,
70
+ reason: 'this check passes in an empty directory, with none of the work present, so passing here says nothing about the work',
71
+ };
72
+ }
73
+ return {
74
+ probed: true,
75
+ canFail: true,
76
+ exit: result.status,
77
+ reason: 'this check fails when the work is absent, so passing it means something',
78
+ };
79
+ } finally {
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ module.exports = { probeVerifierCanFail, absolutePathIn, PROBE_TIMEOUT_MS };
package/lib/fleet.js CHANGED
@@ -24,13 +24,15 @@ const {
24
24
  } = require('./brief-ledger');
25
25
  const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
26
26
  const { resolveDefaultVerifier } = require('./default-verifier');
27
- const { rankEngines } = require('./router-brain');
27
+ const { rankEnginesDetailed } = require('./router-brain');
28
28
  const {
29
29
  buildOneLapValidatorPrompt,
30
30
  parseOneLapValidatorVerdict,
31
31
  } = require('./one-lap-validator');
32
32
  const { listWorktrees } = require('../commands/worktree');
33
33
  const { isConductorStatusLine } = require('./conductor-artifacts');
34
+ const { matchLessons } = require('./lesson-preflight');
35
+ const { matchTaste } = require('./taste-lessons');
34
36
 
35
37
  // Lanes a fleet may never staff on its own: the human keeps irreversible
36
38
  // calls. Mirrors the autoland denied lanes.
@@ -94,6 +96,30 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
94
96
  '',
95
97
  'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
96
98
  );
99
+ const lessonFiles = [
100
+ ...(Array.isArray(task.files) ? task.files : []),
101
+ ...(Array.isArray(task.metadata && task.metadata.files) ? task.metadata.files : []),
102
+ ...fileSurface(task),
103
+ ];
104
+ const briefText = lines.join('\n');
105
+ const preflightRoot = worktreePath || process.cwd();
106
+ const lessons = matchLessons({
107
+ briefText,
108
+ files: lessonFiles,
109
+ root: preflightRoot,
110
+ });
111
+ if (lessons.length) {
112
+ lines.push('', '## lessons that apply', ...lessons.map((lesson) => `- ${lesson.text}`));
113
+ }
114
+ const requestedTasteScope = String(
115
+ task.scope || (task.metadata && task.metadata.scope) || task.tag || 'any'
116
+ ).toLowerCase();
117
+ const taste = matchTaste({ briefText, scope: requestedTasteScope, root: preflightRoot });
118
+ if (taste.length) {
119
+ lines.push('', "## the owner's taste", ...taste.map((entry) => (
120
+ `- The operator's verdict is ${entry.verdict} for "${entry.subject}". The reason is: ${entry.why}`
121
+ )));
122
+ }
97
123
  return lines.join('\n');
98
124
  }
99
125
 
@@ -396,13 +422,18 @@ function installedFleetEngines(root) {
396
422
  return normalizeInstalledEngines(roster(root).filter((e) => e.installed));
397
423
  }
398
424
 
399
- function rankFleetEngines(engines, root = process.cwd()) {
400
- return rankEngines(normalizeInstalledEngines(engines), {
425
+ function rankFleetEnginesDetailed(engines, root = process.cwd(), options = {}) {
426
+ return rankEnginesDetailed(normalizeInstalledEngines(engines), {
401
427
  root,
402
428
  taskType: 'executor',
429
+ lowStakes: options.lowStakes === true,
403
430
  });
404
431
  }
405
432
 
433
+ function rankFleetEngines(engines, root = process.cwd()) {
434
+ return rankFleetEnginesDetailed(engines, root).candidates;
435
+ }
436
+
406
437
  function nextInstalledFleetEngine(current, { root = process.cwd(), installedEngines = null } = {}) {
407
438
  const engines = installedEngines ? normalizeInstalledEngines(installedEngines) : installedFleetEngines(root);
408
439
  const currentName = String(current || '').trim();
@@ -570,27 +601,30 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
570
601
  // ---------------------------------------------------------------------------
571
602
  // T2 — staffing
572
603
 
604
+ const {
605
+ taskTagTokens,
606
+ isDecisionHoldTag,
607
+ isDecisionTask,
608
+ DECISION_REFUSE_REASON,
609
+ } = require('./task-decision');
610
+
573
611
  function taskTags(task) {
574
- const fromTags = Array.isArray(task.tags) ? task.tags : [];
575
- const fromTag = task && task.tag ? [task.tag] : [];
576
612
  // Tags added after creation live in metadata.tags (`atris task tag`); a
577
613
  // fleet that only read task.tags/title hashtags would ignore an owner-hold
578
614
  // flag stamped on a live task and keep restaffing it (CLI-879).
579
- const fromMeta = task && task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
580
- const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
581
- return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle].map((t) => String(t).toLowerCase());
615
+ return taskTagTokens(task);
582
616
  }
583
617
 
584
618
  // A task flagged for a human decision is never fleet-staffable, whatever its
585
- // lane. Mirrors the sweep's needs-human hold so both loops agree.
619
+ // lane. Mirrors the sweep's needs-human hold so both loops agree. Also honors
620
+ // the clearer `decision` tag so policy questions stay off autonomous lanes.
586
621
  function isHumanHoldTag(tag) {
587
- const normalized = String(tag).trim().toLowerCase().replace(/_/g, '-');
588
- return normalized === 'needs-human' || normalized === 'needshuman';
622
+ return isDecisionHoldTag(tag);
589
623
  }
590
624
 
591
625
  function isSafeLane(task) {
626
+ if (isDecisionTask(task)) return false;
592
627
  const tags = taskTags(task);
593
- if (tags.some(isHumanHoldTag)) return false;
594
628
  return !tags.some((t) => DENIED_TAGS.includes(t));
595
629
  }
596
630
 
@@ -822,9 +856,12 @@ module.exports = {
822
856
  isSafeLane,
823
857
  taskTags,
824
858
  nextInstalledFleetEngine,
859
+ rankFleetEnginesDetailed,
825
860
  dispatchToEngine,
826
861
  taskTags,
827
862
  isHumanHoldTag,
863
+ isDecisionTask,
864
+ DECISION_REFUSE_REASON,
828
865
  isSafeLane,
829
866
  staffFlight,
830
867
  assignEngines,
@@ -2105,15 +2142,24 @@ async function runFleetFlight({
2105
2142
  guardCliLink = guardGlobalCliLink,
2106
2143
  } = {}) {
2107
2144
  const cli = ownCli || defaultOwnCli(root);
2108
- const roster = engines || (() => {
2145
+ // Staff first, rank second: every task staffFlight returns already cleared
2146
+ // the safe-lane filter (not a decision row, no denied tags), so the rank
2147
+ // that pairs engines to those tasks is low stakes and the stretch zone
2148
+ // rule may trade the strongest engine for the cheapest learnable one.
2149
+ // Protected or denied work never reaches staffFlight's output, so it never
2150
+ // gets the flag; when nothing is staffed there is no task to rank for and
2151
+ // the flag stays off.
2152
+ const staffedTasks = staffFlight(readProjectionTasks(root), { slots });
2153
+ const rosterDetail = engines ? null : (() => {
2109
2154
  const { roster: fullRoster } = require('../commands/engine');
2110
2155
  const installed = fullRoster(root)
2111
2156
  .filter((e) => e.installed && FLEET_CAPABLE.includes(e.name))
2112
2157
  .map((e) => e.name);
2113
- return rankFleetEngines(installed, root);
2158
+ return rankFleetEnginesDetailed(installed, root, { lowStakes: staffedTasks.length > 0 });
2114
2159
  })();
2160
+ const roster = engines || rosterDetail.candidates;
2115
2161
 
2116
- const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
2162
+ const staffed = assignEngines(staffedTasks, roster);
2117
2163
  // The receipt path is decided BEFORE landings so each task's ready-proof
2118
2164
  // can cite it — the proof policy certifies receipt-backed proofs agent-side.
2119
2165
  const receiptPath = path.join(root, 'atris', 'runs', `fleet-${nowStamp()}.json`);
@@ -2123,10 +2169,14 @@ async function runFleetFlight({
2123
2169
  slots,
2124
2170
  roster,
2125
2171
  staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface })),
2172
+ stretch_zone_pick: rosterDetail && rosterDetail.stretch_zone_pick ? rosterDetail.stretch_zone_pick : null,
2126
2173
  results: [],
2127
2174
  landed: [],
2128
2175
  paused: [],
2129
2176
  };
2177
+ if (flight.stretch_zone_pick) {
2178
+ flight.stretch_zone_note = `low-stakes staffing picked ${flight.stretch_zone_pick} from the stretch zone: the cheapest engine with a learnable track record won this lane.`;
2179
+ }
2130
2180
 
2131
2181
  log('');
2132
2182
  log(` fleet — ${roster.length} engine${roster.length === 1 ? '' : 's'} ready, ${staffed.length} task${staffed.length === 1 ? '' : 's'} staffed`);
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+
5
+ // One synchronous git runner for commands that shell out to git.
6
+ // check: true throws on a non-zero exit; check: false hands back the raw result.
7
+ function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
8
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
9
+ if (check && result.status !== 0) {
10
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
11
+ }
12
+ return result;
13
+ }
14
+
15
+ module.exports = { runGit };
@@ -0,0 +1,37 @@
1
+ // Shared JSON file read/write so every call site fails the same way.
2
+ //
3
+ // Bare `JSON.parse(fs.readFileSync(...))` drifted across commands: some sites
4
+ // caught, some returned null, some returned {}. These two helpers keep the
5
+ // tolerant shape in one place.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Read and parse a JSON file, returning `fallback` when it is missing or corrupt.
12
+ * @param {string} filePath
13
+ * @param {*} [fallback=null]
14
+ * @returns {*}
15
+ */
16
+ function readJson(filePath, fallback = null) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Write a value as pretty JSON with a trailing newline, creating parent dirs.
26
+ * @param {string} filePath
27
+ * @param {*} value
28
+ * @param {{ indent?: number }} [options]
29
+ * @returns {string} the path written
30
+ */
31
+ function writeJson(filePath, value, { indent = 2 } = {}) {
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ fs.writeFileSync(filePath, JSON.stringify(value, null, indent) + '\n', 'utf8');
34
+ return filePath;
35
+ }
36
+
37
+ module.exports = { readJson, writeJson };
@@ -2,8 +2,8 @@
2
2
 
3
3
  const knownCommands = ['init', 'log', 'logs', 'wish', 'drill', 'dream', 'now', 'goal', 'wtf', 'orb', 'radar', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
- 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'agents', 'probe', 'worktree', 'land', 'autoland', 'drive', 'aeo', 'slop', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
5
+ 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
7
  'github', 'vercel', 'supabase', 'linear', 'stripe', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'feed', 'brief'];
9
9