atris 3.65.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.
- package/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +28 -2
- package/commands/autoland.js +62 -0
- package/commands/brain.js +4 -0
- package/commands/engine.js +23 -1
- package/commands/link.js +367 -0
- package/commands/loops.js +4 -1
- package/commands/member.js +8 -1
- package/commands/orb.js +1 -1
- package/commands/xp.js +2 -0
- package/commands/youtube.js +1 -1
- package/lib/auto-accept-certified.js +89 -9
- package/lib/cleanup-summary.js +129 -0
- package/lib/engine-ask.js +3 -2
- package/lib/engine-registry.js +203 -15
- package/lib/idle-review-sweep.js +130 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +18 -4
- package/lib/member-result.js +303 -0
- package/lib/roster-models.js +1 -1
- package/lib/runner-command.js +4 -4
- package/lib/task-list-keeper.js +25 -4
- package/lib/trust-tiers.js +131 -14
- package/lib/verify-worktree-rewrite.js +115 -0
- package/lib/voice-gate.js +2 -0
- package/package.json +1 -1
- package/scripts/det/ytnotes +1 -1
|
@@ -95,7 +95,7 @@ Smoke: `reply with the word OK`, run from `$HOME`, 120s cap, one at a time on a
|
|
|
95
95
|
| Cursor | `cursor-agent --trust --model composer-2.5 -p "<prompt>"` (run from the target repo) | `composer-2.5` | 2026-09-23 | 37.5 | `--trust` required for non-interactive. |
|
|
96
96
|
| Fable | `atris engine fable --timeout 120 --json "<question>"` | `claude-fable-5` | 2026-09-23 | 21.4 | Canonical read-only FABLE ask with receipt; scale `--timeout` to the work. |
|
|
97
97
|
| Composer | `cursor-agent --trust --model composer-2.5 -p "<prompt>"` | `composer-2.5` via Cursor | 2026-09-23 | 37.5 | Same binary as Cursor. |
|
|
98
|
-
| Haiku | `claude -p "<prompt>" --model claude-haiku-
|
|
98
|
+
| Haiku | `claude -p "<prompt>" --model claude-haiku-5-5 --output-format json --no-session-persistence` | `claude-haiku-5-5` | 2026-10-07 | 9.5 | Fast validation and bounded read-only checks. |
|
|
99
99
|
| Devin (build) | `devin -p --permission-mode dangerous --model swe-2-max --prompt-file <brief>` (run from an isolated worktree) | `swe-2-max` (Free tag in `devin models list`, 2026-09-23) | 2026-09-23 | 14.0 | In `-p` mode the default permission mode rejects shell commands too, so even a read-only audit dies after one step (2026-09-23). Keep the brief outside the worktree. Long runs (5+ min) can print nothing even on success: judge by the worktree diff. `devin cloud` outlives this machine. |
|
|
100
100
|
| Devin (search) | `devin -p --permission-mode dangerous --model swe-1.7-lightning -- "<prompt>"` | `swe-1.7-lightning` (paid, no Free tag on 2026-09-23) | 2026-09-23 | 13.9 | |
|
|
101
101
|
| Grok | `grok --always-approve --model grok-4.7 -p "<prompt>"` (run from the target repo) | `grok-4.7` | 2026-09-23 | 11.7 | Unpinned default is now `grok-4.7-build-fast`; `grok models` lists the menu. Uses grok.com login. `--best-of-n <N>` for tricky bounded builds. |
|
|
@@ -119,7 +119,7 @@ Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `
|
|
|
119
119
|
|
|
120
120
|
| Engine | Flag | Alternates on the live menu |
|
|
121
121
|
|--------|------|-----------------------------|
|
|
122
|
-
| Claude | `--model <id>` | `claude-opus-5-5`, `claude-haiku-
|
|
122
|
+
| Claude | `--model <id>` | `claude-opus-5-5`, `claude-haiku-5-5` |
|
|
123
123
|
| Devin | `--model <id>` | `swe-2-max`, `swe-2-high`, `swe-2-medium` (Free); `claude-opus-5-5-high`, `gpt-6-sol` (paid) |
|
|
124
124
|
| Cursor | `--model <id>` | `composer-2.5`, `grok-4.7-xhigh`, `claude-opus-5-thinking-high`; `--list-models` |
|
|
125
125
|
| Grok | `--model <id>` | `grok-4.7`, `grok-4.7-build-fast` (default), `grok-4.6`, `grok-4.5`; `grok models` |
|
package/bin/atris.js
CHANGED
|
@@ -632,6 +632,7 @@ function showHelpAll() {
|
|
|
632
632
|
console.log(' rsi - Read the Dream-RSI attempt ledger (trees, attempts, policy, dreams)');
|
|
633
633
|
console.log(` autoland - Approve the policy once; ${require('../lib/autoland').certifiedWorkLandsPhrase(process.cwd())}, you keep irreversible calls`);
|
|
634
634
|
console.log(' engine - engine registry, answer validation, dispatch flights, and live progress');
|
|
635
|
+
console.log(' link - What two things could become together: one line, why, a first thing to make, who makes it');
|
|
635
636
|
console.log(' ci - run github actions jobs locally with runs-on: atris');
|
|
636
637
|
console.log(' router - inspect ax lane outcomes and promote gated reflex overrides');
|
|
637
638
|
console.log(' sign - Co-author trailer on every commit in an atris workspace (on/off/status)');
|
|
@@ -752,6 +753,7 @@ function showHelpAll() {
|
|
|
752
753
|
console.log(' member list - Show all team members');
|
|
753
754
|
console.log(' member activate <n> - Activate a member (link skills, show context)');
|
|
754
755
|
console.log(' member upgrade <n> - Convert flat file to directory format');
|
|
756
|
+
console.log(' member result <n> - Newest run of a member: run record, receipt, artifacts, result number');
|
|
755
757
|
console.log('');
|
|
756
758
|
console.log('Plugin:');
|
|
757
759
|
console.log(' plugin build - Package skills as .plugin for Cowork');
|
|
@@ -1776,9 +1778,28 @@ function showWelcomeVisualization() {
|
|
|
1776
1778
|
// Document health is advisory and must never prevent startup.
|
|
1777
1779
|
}
|
|
1778
1780
|
|
|
1781
|
+
// What the background cleanup did lately, in its own plain words: the
|
|
1782
|
+
// newest line the hourly autoland tick or a project job left in the
|
|
1783
|
+
// journal, plus agent branches parked for a person.
|
|
1784
|
+
try {
|
|
1785
|
+
const lately = require('../lib/cleanup-summary').latestCleanupLine(cwd);
|
|
1786
|
+
if (lately) console.log(row('lately', lately));
|
|
1787
|
+
} catch {
|
|
1788
|
+
// Advisory only; never blocks boot.
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// When the hourly autoland tick is alive it lands, salvages, and clears
|
|
1792
|
+
// finished branches and old agent copies itself, so boot must not hand the
|
|
1793
|
+
// owner that chore ("39 finished pieces to put away"). Only a dead or
|
|
1794
|
+
// missing heartbeat brings the tidy counts and `land --reap` back.
|
|
1795
|
+
let autolandAlive = false;
|
|
1796
|
+
try { autolandAlive = require('../lib/autoland').heartbeatIsLive(cwd); } catch { autolandAlive = false; }
|
|
1797
|
+
|
|
1779
1798
|
// landSummary is expensive (git board classification) - compute once per boot.
|
|
1780
1799
|
let landInfo = null;
|
|
1781
|
-
|
|
1800
|
+
if (!autolandAlive) {
|
|
1801
|
+
try { landInfo = require('../commands/land').landSummary(cwd); } catch (err) { landInfo = null; }
|
|
1802
|
+
}
|
|
1782
1803
|
let rotInfo = null;
|
|
1783
1804
|
try {
|
|
1784
1805
|
const { parseLessons } = require('../lib/memory-view');
|
|
@@ -1792,7 +1813,7 @@ function showWelcomeVisualization() {
|
|
|
1792
1813
|
worktreeDir = path.join(path.dirname(resolved), '.agent-worktrees', path.basename(resolved));
|
|
1793
1814
|
}
|
|
1794
1815
|
let worktrees = 0;
|
|
1795
|
-
if (fs.existsSync(worktreeDir)) {
|
|
1816
|
+
if (!autolandAlive && fs.existsSync(worktreeDir)) {
|
|
1796
1817
|
worktrees = fs.readdirSync(worktreeDir, { withFileTypes: true })
|
|
1797
1818
|
.filter((entry) => entry.isDirectory()).length;
|
|
1798
1819
|
}
|
|
@@ -3171,6 +3192,11 @@ if (command === 'guide') {
|
|
|
3171
3192
|
Promise.resolve(require('../commands/engine').engineCommand(engineArgs))
|
|
3172
3193
|
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
3173
3194
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
3195
|
+
} else if (command === 'link') {
|
|
3196
|
+
// Link: ask the roster's engine what two things could become together.
|
|
3197
|
+
Promise.resolve(require('../commands/link').linkCommand(process.argv.slice(3)))
|
|
3198
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
3199
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
3174
3200
|
} else if (command === 'playbook') {
|
|
3175
3201
|
Promise.resolve()
|
|
3176
3202
|
.then(() => require('../commands/playbook').playbookCommand(process.argv.slice(3)))
|
package/commands/autoland.js
CHANGED
|
@@ -555,6 +555,21 @@ function showStatus(root, args) {
|
|
|
555
555
|
console.log('');
|
|
556
556
|
console.log(` waiting on a human right now: ${waiting.length}, oldest ${waiting[0].hours}h`);
|
|
557
557
|
}
|
|
558
|
+
// What the next tick's idle sweep will do, read-only (no check runs here).
|
|
559
|
+
if (enabled && policy.janitor !== false && policy.idle_sweep !== false) {
|
|
560
|
+
try {
|
|
561
|
+
const idle = require('../lib/idle-review-sweep').sweepIdleReviews(root, { tasks, policy, dryRun: true });
|
|
562
|
+
const days = Number(policy.idle_close_days) > 0 ? Number(policy.idle_close_days) : 7;
|
|
563
|
+
if (idle.closed.length) {
|
|
564
|
+
console.log(` closing on the next tick: ${idle.closed.length} idle over ${days} days that can never land on their own, each with a plain reason.`);
|
|
565
|
+
}
|
|
566
|
+
if (idle.human_waiting.length) {
|
|
567
|
+
console.log(` staying with you: ${idle.human_waiting.length} in money, deploys, security, or customer work; idle time never closes these.`);
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// Preview only.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
558
573
|
console.log('');
|
|
559
574
|
if (!enabled) console.log(" flip it on: atris autoland on (one decision; everything after is receipts)");
|
|
560
575
|
console.log('');
|
|
@@ -798,9 +813,11 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
|
|
|
798
813
|
.filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
|
|
799
814
|
.length;
|
|
800
815
|
const accept = runOwnCli(root, cliArgs, acceptSweepTimeoutMs(pendingReviewRows));
|
|
816
|
+
let acceptResults = [];
|
|
801
817
|
try {
|
|
802
818
|
const parsed = JSON.parse(accept.stdout);
|
|
803
819
|
const results = Array.isArray(parsed.results) ? parsed.results : [];
|
|
820
|
+
acceptResults = results;
|
|
804
821
|
// A refused sweep (ok:false, a guard tripped, policy race) carries no
|
|
805
822
|
// summary fields. Name the reason instead of leaving nulls that read as
|
|
806
823
|
// "no work": a blind heartbeat must say WHY it is blind.
|
|
@@ -963,6 +980,51 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
|
|
|
963
980
|
recordLandingSweepState(state, null, { error: receipt.reap_error });
|
|
964
981
|
}
|
|
965
982
|
|
|
983
|
+
// 3c2. idle work resolves itself, every tick. Finished work that sat still
|
|
984
|
+
// past the grace period (policy.idle_close_days, default 7) and can never
|
|
985
|
+
// land on its own is closed with a plain reason; done and long-idle rows
|
|
986
|
+
// leave the list. This used to run only when someone opened a session, so
|
|
987
|
+
// nights and trips piled up work nobody would ever land. Protected lanes
|
|
988
|
+
// (money, deploys, security, customer, outward) are never closed by idle
|
|
989
|
+
// time. Off switch: policy.idle_sweep === false or policy.janitor === false.
|
|
990
|
+
if (policy.janitor !== false && policy.idle_sweep !== false) {
|
|
991
|
+
try {
|
|
992
|
+
const liveReasons = new Map(acceptResults
|
|
993
|
+
.filter((r) => r && r.ref && r.reason && r.action !== 'accepted')
|
|
994
|
+
.map((r) => [r.ref, r.reason]));
|
|
995
|
+
const kept = require('../lib/task-list-keeper').keepWorkspaceTaskList(root, { actor: 'autoland', includeDone: false });
|
|
996
|
+
const idle = require('../lib/idle-review-sweep').sweepIdleReviews(root, {
|
|
997
|
+
tasks: readProjection(root),
|
|
998
|
+
policy,
|
|
999
|
+
liveReasons,
|
|
1000
|
+
});
|
|
1001
|
+
const putAway = (kept.put_away || []).length + (kept.reaped || []).length;
|
|
1002
|
+
if (putAway + idle.closed.length > 0) require('./task').refreshKeptTaskList(root);
|
|
1003
|
+
receipt.idle_sweep = {
|
|
1004
|
+
closed: idle.closed.map((c) => ({ ref: c.ref, reason: c.reason })),
|
|
1005
|
+
put_away: putAway,
|
|
1006
|
+
human_waiting: idle.human_waiting.length,
|
|
1007
|
+
};
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
receipt.idle_sweep_error = String((err && err.message) || err).slice(0, 200);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const { appendOvernightNote, tickSummaryLine } = require('../lib/cleanup-summary');
|
|
1014
|
+
const line = tickSummaryLine({
|
|
1015
|
+
landed: (receipt.landed || []).length,
|
|
1016
|
+
closed: receipt.idle_sweep ? receipt.idle_sweep.closed.length : 0,
|
|
1017
|
+
putAway: receipt.idle_sweep ? receipt.idle_sweep.put_away : 0,
|
|
1018
|
+
needYou: receipt.idle_sweep ? receipt.idle_sweep.human_waiting : protectedWaiting.length,
|
|
1019
|
+
});
|
|
1020
|
+
if (line) {
|
|
1021
|
+
appendOvernightNote(root, line);
|
|
1022
|
+
receipt.summary_line = line;
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
// The journal line is a courtesy; it never fails the tick.
|
|
1026
|
+
}
|
|
1027
|
+
|
|
966
1028
|
// 3d. wish sweep: queued wishes are allowed to wake a mission without a
|
|
967
1029
|
// foreground `atris wish` session, but they must never endanger landing.
|
|
968
1030
|
try {
|
package/commands/brain.js
CHANGED
|
@@ -1140,6 +1140,10 @@ function taskEpisodeScorecard(root, episode, workspace, ts = new Date().toISOStr
|
|
|
1140
1140
|
task_title: episode.state && episode.state.title || null,
|
|
1141
1141
|
task_tag: episode.state && episode.state.tag || null,
|
|
1142
1142
|
actor: episode.action && episode.action.actor || null,
|
|
1143
|
+
// Who built the work. `actor` is the reviewer; trust is earned by the builder.
|
|
1144
|
+
builder: episode.state && episode.state.metadata && episode.state.metadata.built_by
|
|
1145
|
+
|| episode.state && episode.state.claimed_by
|
|
1146
|
+
|| null,
|
|
1143
1147
|
reward: Number.isFinite(reward) ? reward : 0,
|
|
1144
1148
|
reward_source: episode.reward && episode.reward.source || 'task_review',
|
|
1145
1149
|
lesson: episode.lesson || '',
|
package/commands/engine.js
CHANGED
|
@@ -57,6 +57,8 @@ const {
|
|
|
57
57
|
rosterJobNameError,
|
|
58
58
|
rosterJobRole,
|
|
59
59
|
readRosterState,
|
|
60
|
+
retiredModelsNamed,
|
|
61
|
+
RETIRED_CLAUDE_MODELS,
|
|
60
62
|
customRosterJobKeys,
|
|
61
63
|
setRosterPick,
|
|
62
64
|
confirmRoster,
|
|
@@ -1161,6 +1163,7 @@ function rosterReport(root = process.cwd(), now = new Date()) {
|
|
|
1161
1163
|
jobs,
|
|
1162
1164
|
expiring,
|
|
1163
1165
|
renew_command: expiring.length ? RENEW_COMMAND : null,
|
|
1166
|
+
upgraded: upgradedModels(state),
|
|
1164
1167
|
team: team.rows,
|
|
1165
1168
|
warnings: [...state.session.warnings, ...state.project.warnings, ...state.machine.warnings, ...team.warnings],
|
|
1166
1169
|
files: {
|
|
@@ -1172,6 +1175,25 @@ function rosterReport(root = process.cwd(), now = new Date()) {
|
|
|
1172
1175
|
};
|
|
1173
1176
|
}
|
|
1174
1177
|
|
|
1178
|
+
// Retired models a roster file still names. Every read runs them as their
|
|
1179
|
+
// replacement, so this is only a heads-up; nobody has to edit the file.
|
|
1180
|
+
function upgradedModels(state) {
|
|
1181
|
+
const found = [];
|
|
1182
|
+
for (const layer of [state.session, state.project, state.machine]) {
|
|
1183
|
+
if (!layer || layer.format === 'none' || !layer.path) continue;
|
|
1184
|
+
let text = '';
|
|
1185
|
+
try { text = fs.readFileSync(layer.path, 'utf8'); } catch { continue; }
|
|
1186
|
+
for (const from of retiredModelsNamed(text)) {
|
|
1187
|
+
if (!found.some((item) => item.from === from)) found.push({ from, to: RETIRED_CLAUDE_MODELS[from] });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return found;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function renderUpgradedLine(upgraded) {
|
|
1194
|
+
return (upgraded || []).map((item) => `note: ${modelLabel(item.from)} is retired, so your roster runs ${modelLabel(item.to)} in its place. no edit needed.`).join('\n');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1175
1197
|
// Every worker, in any job, whose end date is today or within the next
|
|
1176
1198
|
// seven days. Workers already past their date show on their own lines. A
|
|
1177
1199
|
// job set for this shell only is skipped: roster confirm does not renew it.
|
|
@@ -1367,7 +1389,7 @@ function renderRosterWarnings(warnings) {
|
|
|
1367
1389
|
|
|
1368
1390
|
function renderRosterReport(report, { width } = {}) {
|
|
1369
1391
|
const stale = (report.stale || []).map((item) => item.text);
|
|
1370
|
-
const jobs = [renderJobRoster(report.jobs, { width }), renderExpiringLine(report.expiring), ...stale].filter(Boolean).join('\n');
|
|
1392
|
+
const jobs = [renderJobRoster(report.jobs, { width }), renderExpiringLine(report.expiring), ...stale, renderUpgradedLine(report.upgraded)].filter(Boolean).join('\n');
|
|
1371
1393
|
return [jobs, renderTeamRoster(report.team, { width }), renderRosterWarnings(report.warnings)]
|
|
1372
1394
|
.filter(Boolean)
|
|
1373
1395
|
.join('\n\n');
|
package/commands/link.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris link "<thing a>" "<thing b>" [--context "..."] [--team] [--json]
|
|
4
|
+
//
|
|
5
|
+
// Asks what two things could become together. The answer is one short line
|
|
6
|
+
// naming the connection, a plain why, a bounded first thing to make, and the
|
|
7
|
+
// team member who should make it. The question goes to the roster's engine
|
|
8
|
+
// for the job (a `link` job when the roster has one, else the `build` lead),
|
|
9
|
+
// through the same read-only runner `atris engine ask` uses. Nothing is
|
|
10
|
+
// written except the run record line every ask leaves.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
MAX_ASK_PROMPT_BYTES,
|
|
18
|
+
buildReadOnlyEngineInvocation,
|
|
19
|
+
runAskProcess,
|
|
20
|
+
runEngineAskJobs,
|
|
21
|
+
} = require('../lib/engine-ask');
|
|
22
|
+
const { isMemberParked } = require('../lib/member-park');
|
|
23
|
+
|
|
24
|
+
const LINK_TIMEOUT_MS = 180 * 1000;
|
|
25
|
+
const TEAM_FRESH_DAYS = 5;
|
|
26
|
+
const TEAM_NOTE_CHARS = 280;
|
|
27
|
+
const MAX_LINE_WORDS = 12;
|
|
28
|
+
const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
29
|
+
|
|
30
|
+
const SYSTEM_PROMPT = [
|
|
31
|
+
'You look at two things a person cares about and say what they could become together.',
|
|
32
|
+
'',
|
|
33
|
+
'Rules:',
|
|
34
|
+
'- Use only facts stated in the two things, the context, and the team notes below. Do not read files, search, or run anything. Do not invent numbers, names, customers, or results.',
|
|
35
|
+
'- Plain words, the way a person talks. No em dashes. No hype or sales words; say the specific thing.',
|
|
36
|
+
`- line: one sentence of at most ${MAX_LINE_WORDS} words naming the connection.`,
|
|
37
|
+
'- why: two or three short sentences. Each one rests on something in the inputs.',
|
|
38
|
+
'- experiment.label: a small first thing to make, in a few words.',
|
|
39
|
+
'- experiment.scope: one sentence saying what it will do and what it will not do.',
|
|
40
|
+
'- by: one slug from the team list, the member best placed to make it, or null when none fits.',
|
|
41
|
+
'- hunch: true when the connection is a guess rather than grounded in both things; false when both things support it.',
|
|
42
|
+
'- If nothing specific stands out, answer {"line": null, "reason": "<one plain sentence>"} instead.',
|
|
43
|
+
'',
|
|
44
|
+
'Answer with exactly one JSON object and nothing else:',
|
|
45
|
+
'{"line": "...", "why": "...", "experiment": {"label": "...", "scope": "..."}, "by": "slug-or-null", "hunch": false}',
|
|
46
|
+
].join('\n');
|
|
47
|
+
|
|
48
|
+
function usage() {
|
|
49
|
+
return [
|
|
50
|
+
'usage:',
|
|
51
|
+
' atris link "<thing a>" "<thing b>" [--context "<text>"] [--team] [--json]',
|
|
52
|
+
'',
|
|
53
|
+
'asks the roster\'s engine what these two things could become together.',
|
|
54
|
+
'',
|
|
55
|
+
'options:',
|
|
56
|
+
' --context <text> extra facts the answer may use',
|
|
57
|
+
` --team read each member's now.md (fresh within ${TEAM_FRESH_DAYS} days, not parked) to pick who makes it`,
|
|
58
|
+
' --json print the answer as json',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseLinkArgs(args = []) {
|
|
63
|
+
const things = [];
|
|
64
|
+
let context = '';
|
|
65
|
+
let team = false;
|
|
66
|
+
let json = false;
|
|
67
|
+
let help = false;
|
|
68
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
69
|
+
const arg = String(args[index]);
|
|
70
|
+
if (arg === '--help' || arg === '-h') { help = true; continue; }
|
|
71
|
+
if (arg === '--json') { json = true; continue; }
|
|
72
|
+
if (arg === '--team') { team = true; continue; }
|
|
73
|
+
if (arg === '--context') {
|
|
74
|
+
if (index + 1 >= args.length) throw new Error('--context needs text');
|
|
75
|
+
context = String(args[index + 1]);
|
|
76
|
+
index += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (arg.startsWith('--context=')) { context = arg.slice('--context='.length); continue; }
|
|
80
|
+
if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`);
|
|
81
|
+
things.push(arg.trim());
|
|
82
|
+
}
|
|
83
|
+
if (help) return { help: true, json };
|
|
84
|
+
if (things.length !== 2 || !things[0] || !things[1]) throw new Error(usage());
|
|
85
|
+
return { help: false, things, context: context.trim(), team, json };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The roster's worker for this question: a `link` job when the owner made
|
|
89
|
+
// one, else whoever leads `build`.
|
|
90
|
+
function pickLinkWorker(report) {
|
|
91
|
+
const jobs = Array.isArray(report && report.jobs) ? report.jobs : [];
|
|
92
|
+
const row = jobs.find((entry) => entry && entry.job === 'link' && entry.engine)
|
|
93
|
+
|| jobs.find((entry) => entry && entry.job === 'build' && entry.engine);
|
|
94
|
+
if (!row) return null;
|
|
95
|
+
return {
|
|
96
|
+
job: row.job,
|
|
97
|
+
engine: String(row.engine),
|
|
98
|
+
model: row.model ? String(row.model) : '',
|
|
99
|
+
effort: row.effort ? String(row.effort) : '',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rosterMemberNames(report, root) {
|
|
104
|
+
const rows = Array.isArray(report && report.team) ? report.team : [];
|
|
105
|
+
const names = [];
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const name = String((row && row.member) || '').trim();
|
|
108
|
+
if (!name || !MEMBER_NAME_RE.test(name) || names.includes(name)) continue;
|
|
109
|
+
if (isMemberParked(root, name)) continue;
|
|
110
|
+
names.push(name);
|
|
111
|
+
}
|
|
112
|
+
return names;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function oneLine(text) {
|
|
116
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A member's now.md, trimmed to the first lines that say something: no
|
|
120
|
+
// heading marks, no "Updated:" stamp. Skipped when older than five days.
|
|
121
|
+
function teamNote(root, name, now = Date.now()) {
|
|
122
|
+
const file = path.join(root, 'atris', 'team', name, 'now.md');
|
|
123
|
+
let stat;
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
stat = fs.statSync(file);
|
|
127
|
+
text = fs.readFileSync(file, 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (now - stat.mtimeMs > TEAM_FRESH_DAYS * 86400000) return null;
|
|
132
|
+
const lines = text.split(/\r?\n/)
|
|
133
|
+
.map((line) => line.replace(/^#+\s*/, '').trim())
|
|
134
|
+
.filter((line) => line && line.toLowerCase() !== 'now' && !/^updated:/i.test(line));
|
|
135
|
+
const note = oneLine(lines.join(' '));
|
|
136
|
+
if (!note) return null;
|
|
137
|
+
return note.length > TEAM_NOTE_CHARS ? `${note.slice(0, TEAM_NOTE_CHARS - 3)}...` : note;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildLinkPrompt({ things, context, members, notes }) {
|
|
141
|
+
const parts = [
|
|
142
|
+
SYSTEM_PROMPT,
|
|
143
|
+
'',
|
|
144
|
+
`Thing A: ${oneLine(things[0])}`,
|
|
145
|
+
`Thing B: ${oneLine(things[1])}`,
|
|
146
|
+
];
|
|
147
|
+
if (context) parts.push(`Context: ${oneLine(context)}`);
|
|
148
|
+
parts.push('');
|
|
149
|
+
if (notes && notes.length) {
|
|
150
|
+
parts.push('Team (slug: what they are on now):');
|
|
151
|
+
for (const entry of notes) parts.push(`- ${entry.name}: ${entry.note}`);
|
|
152
|
+
} else {
|
|
153
|
+
parts.push(`Team slugs: ${members.length ? members.join(', ') : '(none)'}`);
|
|
154
|
+
}
|
|
155
|
+
return parts.join('\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Notes go in until the prompt would pass the ask limit; whoever does not
|
|
159
|
+
// fit is still a valid slug, named on the last line.
|
|
160
|
+
function fitTeamNotes(base, notes, members, maxBytes = MAX_ASK_PROMPT_BYTES - 1024) {
|
|
161
|
+
const kept = [];
|
|
162
|
+
for (const entry of notes) {
|
|
163
|
+
const next = buildLinkPrompt({ ...base, members, notes: [...kept, entry] });
|
|
164
|
+
if (Buffer.byteLength(next) > maxBytes) break;
|
|
165
|
+
kept.push(entry);
|
|
166
|
+
}
|
|
167
|
+
const rest = members.filter((name) => !kept.some((entry) => entry.name === name));
|
|
168
|
+
let prompt = buildLinkPrompt({ ...base, members, notes: kept });
|
|
169
|
+
if (kept.length && rest.length) prompt += `\nOther team slugs: ${rest.join(', ')}`;
|
|
170
|
+
return prompt;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The same read-only invocation `engine ask` builds, run from the temp
|
|
174
|
+
// folder so the workspace's own notes and memory do not leak into an answer
|
|
175
|
+
// that may use only its inputs. Claude-family engines also lose their read
|
|
176
|
+
// tools for the same reason.
|
|
177
|
+
function linkInvocation(job, timeoutMs) {
|
|
178
|
+
const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs, effort: job.effort || '' });
|
|
179
|
+
const args = [...invocation.args];
|
|
180
|
+
const tools = args.indexOf('--tools');
|
|
181
|
+
if (tools >= 0 && tools + 1 < args.length) args[tools + 1] = '';
|
|
182
|
+
return { ...invocation, args, cwd: os.tmpdir() };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function executeLinkJob(job, { timeoutMs, signal } = {}) {
|
|
186
|
+
const invocation = linkInvocation(job, timeoutMs);
|
|
187
|
+
return runAskProcess(invocation, { cwd: invocation.cwd, timeoutMs, signal });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The engine's text to one object: fences stripped, first "{" to last "}".
|
|
191
|
+
function extractJsonObject(text) {
|
|
192
|
+
const raw = String(text || '').replace(/```(?:json)?/gi, '').trim();
|
|
193
|
+
const start = raw.indexOf('{');
|
|
194
|
+
const end = raw.lastIndexOf('}');
|
|
195
|
+
if (start < 0 || end <= start) return null;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
198
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Em and en dashes become commas, so a rule the model missed never reaches
|
|
205
|
+
// the board.
|
|
206
|
+
function plain(text) {
|
|
207
|
+
return oneLine(String(text == null ? '' : text)
|
|
208
|
+
.replace(/\s*[\u2014\u2013]\s*/g, ', ')
|
|
209
|
+
.replace(/,\s*,/g, ','));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function wordCount(text) {
|
|
213
|
+
return plain(text).split(' ').filter(Boolean).length;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Shape the answer into the contract. Returns { ok, value } or
|
|
217
|
+
// { ok: false, error }.
|
|
218
|
+
function normalizeLinkAnswer(parsed, members) {
|
|
219
|
+
if (!parsed) return { ok: false, error: 'the engine did not answer with a json object' };
|
|
220
|
+
if (parsed.line === null || parsed.line === undefined || !plain(parsed.line)) {
|
|
221
|
+
const reason = plain(parsed.reason) || 'nothing specific stood out between these two';
|
|
222
|
+
return { ok: true, value: { line: null, reason } };
|
|
223
|
+
}
|
|
224
|
+
const line = plain(parsed.line);
|
|
225
|
+
if (wordCount(line) > MAX_LINE_WORDS) {
|
|
226
|
+
return { ok: false, error: `the line has ${wordCount(line)} words, more than ${MAX_LINE_WORDS}` };
|
|
227
|
+
}
|
|
228
|
+
const experiment = parsed.experiment && typeof parsed.experiment === 'object' ? parsed.experiment : {};
|
|
229
|
+
const label = plain(experiment.label);
|
|
230
|
+
const scope = plain(experiment.scope);
|
|
231
|
+
const why = plain(parsed.why);
|
|
232
|
+
if (!why || !label || !scope) return { ok: false, error: 'the answer is missing why, experiment.label, or experiment.scope' };
|
|
233
|
+
const by = String(parsed.by == null ? '' : parsed.by).trim();
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
value: {
|
|
237
|
+
line,
|
|
238
|
+
why,
|
|
239
|
+
experiment: { label, scope },
|
|
240
|
+
by: by && members.includes(by) ? by : null,
|
|
241
|
+
hunch: parsed.hunch === true || String(parsed.hunch).toLowerCase() === 'true',
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function compactFailure(answer) {
|
|
247
|
+
if (!answer) return 'no answer';
|
|
248
|
+
if (answer.reason === 'timeout') return `timed out after ${Math.round((answer.duration_ms || 0) / 1000)}s`;
|
|
249
|
+
const detail = String(answer.stderr || '').trim().split(/\r?\n/).find(Boolean);
|
|
250
|
+
return detail || String(answer.reason || 'failed').replace(/_/g, ' ');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// One run-record line, job "ask", task "link", so `atris engine roster
|
|
254
|
+
// --runs` shows how long links take. Best effort.
|
|
255
|
+
function recordLinkRun(root, worker, answer, startedAt) {
|
|
256
|
+
try {
|
|
257
|
+
const { appendRosterRun, parseRunUsage } = require('../lib/roster-runs');
|
|
258
|
+
let outcome = 'landed';
|
|
259
|
+
if (answer && (answer.timed_out || answer.reason === 'timeout')) outcome = 'stalled';
|
|
260
|
+
else if (!answer || !answer.ok) outcome = 'failed';
|
|
261
|
+
appendRosterRun(root, {
|
|
262
|
+
at: startedAt,
|
|
263
|
+
job: 'ask',
|
|
264
|
+
engine: worker.engine,
|
|
265
|
+
model: worker.model,
|
|
266
|
+
...(worker.effort ? { effort: worker.effort } : {}),
|
|
267
|
+
max_seconds: Math.round(LINK_TIMEOUT_MS / 1000),
|
|
268
|
+
seconds: Math.max(0, (Number(answer && answer.duration_ms) || 0) / 1000),
|
|
269
|
+
outcome,
|
|
270
|
+
...(outcome === 'landed' ? {} : { detail: compactFailure(answer) }),
|
|
271
|
+
task: 'link',
|
|
272
|
+
source: 'link',
|
|
273
|
+
...parseRunUsage({ stdout: answer && answer.stdout, stderr: answer && answer.stderr }),
|
|
274
|
+
});
|
|
275
|
+
} catch { /* best effort */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function printHuman(value, ran) {
|
|
279
|
+
if (value.line === null) {
|
|
280
|
+
console.log(`\nnothing specific: ${value.reason}\n`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.log(`\n${value.line}${value.hunch ? ' (hunch)' : ''}`);
|
|
284
|
+
console.log(`\n ${value.why}`);
|
|
285
|
+
console.log(`\n make: ${value.experiment.label}`);
|
|
286
|
+
console.log(` ${value.experiment.scope}`);
|
|
287
|
+
console.log(` by: ${value.by || 'nobody on the team fits yet'}`);
|
|
288
|
+
console.log(`\n ${ran.engine}${ran.model ? ` (${ran.model})` : ''}, ${ran.seconds}s\n`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function linkCommand(args = [], deps = {}) {
|
|
292
|
+
const root = deps.root || process.cwd();
|
|
293
|
+
let parsed;
|
|
294
|
+
try {
|
|
295
|
+
parsed = parseLinkArgs(args);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error(`link: ${error.message}`);
|
|
298
|
+
return 2;
|
|
299
|
+
}
|
|
300
|
+
if (parsed.help) {
|
|
301
|
+
console.log(usage());
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const fail = (error, extra = {}) => {
|
|
306
|
+
if (parsed.json) console.log(JSON.stringify({ ok: false, error, ...extra }, null, 2));
|
|
307
|
+
else console.error(`link: ${error}`);
|
|
308
|
+
return 1;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
let report;
|
|
312
|
+
try {
|
|
313
|
+
report = deps.rosterReport ? deps.rosterReport(root) : require('./engine').rosterReport(root);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return fail(`could not read the roster: ${error.message || error}`);
|
|
316
|
+
}
|
|
317
|
+
const worker = pickLinkWorker(report);
|
|
318
|
+
if (!worker) return fail('the roster has no build engine; run: atris engine roster');
|
|
319
|
+
|
|
320
|
+
const members = rosterMemberNames(report, root);
|
|
321
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
322
|
+
const notes = parsed.team
|
|
323
|
+
? members.map((name) => ({ name, note: teamNote(root, name, now) })).filter((entry) => entry.note)
|
|
324
|
+
: [];
|
|
325
|
+
const base = { things: parsed.things, context: parsed.context };
|
|
326
|
+
const prompt = notes.length
|
|
327
|
+
? fitTeamNotes(base, notes, members)
|
|
328
|
+
: buildLinkPrompt({ ...base, members, notes: [] });
|
|
329
|
+
|
|
330
|
+
const startedAt = new Date().toISOString();
|
|
331
|
+
const job = { engine: worker.engine, model: worker.model, ...(worker.effort ? { effort: worker.effort } : {}), prompt, label: 'link' };
|
|
332
|
+
const [answer] = await runEngineAskJobs([job], {
|
|
333
|
+
root,
|
|
334
|
+
concurrency: 1,
|
|
335
|
+
timeoutMs: deps.timeoutMs || LINK_TIMEOUT_MS,
|
|
336
|
+
executeAskJob: deps.executeAskJob || executeLinkJob,
|
|
337
|
+
});
|
|
338
|
+
if (!deps.executeAskJob) recordLinkRun(root, worker, answer, startedAt);
|
|
339
|
+
|
|
340
|
+
const ran = {
|
|
341
|
+
job: worker.job,
|
|
342
|
+
engine: worker.engine,
|
|
343
|
+
model: worker.model || null,
|
|
344
|
+
seconds: Math.round((Number(answer && answer.duration_ms) || 0) / 100) / 10,
|
|
345
|
+
team_notes: parsed.team ? notes.length : null,
|
|
346
|
+
};
|
|
347
|
+
if (!answer || !answer.ok) return fail(`the engine did not answer: ${compactFailure(answer)}`, { ran });
|
|
348
|
+
|
|
349
|
+
const result = normalizeLinkAnswer(extractJsonObject(answer.stdout), members);
|
|
350
|
+
if (!result.ok) return fail(result.error, { ran, raw: String(answer.stdout || '').trim().slice(0, 2000) });
|
|
351
|
+
|
|
352
|
+
if (parsed.json) console.log(JSON.stringify({ ...result.value, ran }, null, 2));
|
|
353
|
+
else printHuman(result.value, ran);
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
SYSTEM_PROMPT,
|
|
359
|
+
linkCommand,
|
|
360
|
+
parseLinkArgs,
|
|
361
|
+
pickLinkWorker,
|
|
362
|
+
teamNote,
|
|
363
|
+
fitTeamNotes,
|
|
364
|
+
extractJsonObject,
|
|
365
|
+
normalizeLinkAnswer,
|
|
366
|
+
linkInvocation,
|
|
367
|
+
};
|
package/commands/loops.js
CHANGED
|
@@ -276,7 +276,10 @@ function structuralProblems({ root, filePath, text, nowMs }) {
|
|
|
276
276
|
|
|
277
277
|
const owner = text.match(/\*\*Owner:\*\*\s*`team\/([^`]+)`/)?.[1]?.trim();
|
|
278
278
|
if (!owner) problems.push('no Owner field');
|
|
279
|
-
|
|
279
|
+
// Members live in atris/team/; older workspaces kept a root team/ folder.
|
|
280
|
+
else if (![path.join(root, 'atris', 'team', owner), path.join(root, 'team', owner)].some((dir) => fs.existsSync(dir))) {
|
|
281
|
+
problems.push(`owner team/${owner} does not exist`);
|
|
282
|
+
}
|
|
280
283
|
|
|
281
284
|
const wiki = text.match(/\*\*Wiki:\*\*\s*\[[^\]]+\]\(([^)]+)\)/)?.[1]?.trim();
|
|
282
285
|
if (!wiki) {
|
package/commands/member.js
CHANGED
|
@@ -9412,7 +9412,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9412
9412
|
// Subcommands that take a member name as args[0] otherwise treat `--help` as
|
|
9413
9413
|
// a name and error with "Member '--help' not found". `create`/`new` handle
|
|
9414
9414
|
// help themselves (with subcommand-specific usage), leave those alone.
|
|
9415
|
-
const HELP_AWARE_SUBCOMMANDS = new Set(['create', 'new', 'run', 'install']);
|
|
9415
|
+
const HELP_AWARE_SUBCOMMANDS = new Set(['create', 'new', 'run', 'install', 'result']);
|
|
9416
9416
|
if (!HELP_AWARE_SUBCOMMANDS.has(subcommand) && (args[0] === '-h' || args[0] === '--help')) {
|
|
9417
9417
|
subcommand = undefined;
|
|
9418
9418
|
}
|
|
@@ -9449,6 +9449,11 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9449
9449
|
return memberWake(args[0], ...args.slice(1));
|
|
9450
9450
|
case 'run':
|
|
9451
9451
|
return memberRun(args[0], ...args.slice(1));
|
|
9452
|
+
case 'result': {
|
|
9453
|
+
const code = require('../lib/member-result').memberResultCommand(args);
|
|
9454
|
+
if (code) process.exitCode = code;
|
|
9455
|
+
return undefined;
|
|
9456
|
+
}
|
|
9452
9457
|
case 'ping':
|
|
9453
9458
|
return memberPing(args[0], ...args.slice(1));
|
|
9454
9459
|
case 'loop':
|
|
@@ -9498,6 +9503,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9498
9503
|
console.log(' goal-from-score <name> Create/reuse an active goal from Team score evidence');
|
|
9499
9504
|
console.log(' wake <name> Read Mission state and decide tick/wait/ask/stop');
|
|
9500
9505
|
console.log(' run <name> ["..."] Start a budgeted member mission, or run its active Mission Runtime');
|
|
9506
|
+
console.log(' result <name> Newest run result: run record, receipt, artifacts, number [--since <iso>] [--json]');
|
|
9501
9507
|
console.log(' loop <name|--all> Repeat wake on a bounded cadence with a no-overlap lease');
|
|
9502
9508
|
console.log(' tick <name> Propose the next bounded experiment');
|
|
9503
9509
|
console.log(' review <name> <id> Accept/discard an experiment with proof');
|
|
@@ -9533,6 +9539,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9533
9539
|
console.log(' atris member wake growth --json');
|
|
9534
9540
|
console.log(' atris member run growth "improve onboarding proof" --verify "npm test" --minutes 30 --json');
|
|
9535
9541
|
console.log(' atris member run growth --mission <mission-id> --max-ticks 1 --json');
|
|
9542
|
+
console.log(' atris member result growth --json');
|
|
9536
9543
|
console.log(' atris member wake growth --execute --confirm-autonomy-policy');
|
|
9537
9544
|
console.log(' atris member supervisor recommendations --json');
|
|
9538
9545
|
console.log(' atris member objective-generator proposals --json');
|
package/commands/orb.js
CHANGED
|
@@ -42,7 +42,7 @@ function parseEngine(args) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function engineInvocation(engine, prompt) {
|
|
45
|
-
if (engine === 'codex') return { command: 'codex', args: ['exec', prompt] };
|
|
45
|
+
if (engine === 'codex') return { command: 'codex', args: ['exec', '--skip-git-repo-check', prompt] };
|
|
46
46
|
if (engine === 'fast') return { command: 'ax', args: ['--fast', '--print', prompt] };
|
|
47
47
|
return { command: 'claude', args: ['-p', prompt] };
|
|
48
48
|
}
|