atris 3.32.0 → 3.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,14 @@ Computer = workspace + files + tools + secrets + memory + agents + validation lo
12
12
 
13
13
  A computer can be personal or shared by a business. It can run a job like code, research, CRM, reporting, event ops, support, or business ops.
14
14
 
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ npm install -g atris
19
+ cd your-project
20
+ atris init && atris
21
+ ```
22
+
15
23
  ## For Coding Agents
16
24
 
17
25
  **Read [`FOR_AGENTS.md`](FOR_AGENTS.md) first** — why agents adopt Atris, 60-second start, spread-it instructions.
@@ -47,6 +55,7 @@ Then read the workspace's `atris/atris.md` and follow it exactly. `atris.md` is
47
55
  | `atris/features/` | Feature packs with `idea.md`, `build.md`, `validate.md` |
48
56
  | `atris/skills/` | Reusable skills for agents |
49
57
  | `atris/team/` | Team member specs and local context |
58
+ | `atris/wiki/` | Repo memory, briefs, concepts, and wiki health |
50
59
 
51
60
  ```text
52
61
  atris/
@@ -56,7 +65,8 @@ atris/
56
65
  ├── logs/
57
66
  ├── features/
58
67
  ├── skills/
59
- └── team/
68
+ ├── team/
69
+ └── wiki/
60
70
  ```
61
71
 
62
72
  ## Install
@@ -232,6 +242,38 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
232
242
  - `atris pull` and `atris push` sync cloud workspaces and journals
233
243
  - `atris live` keeps a business brain fresh by checking/fixing the workspace, pushing local state, pulling cloud state, and pushing again after local changes go quiet
234
244
 
245
+ ## Engines
246
+
247
+ An engine is the intelligence that builds a mission. Every installed headless coding CLI is a swappable worker behind one contract: a bounded prompt goes in, verified proof comes out, and engines never self-certify their own work. The house default is `atris-fast` (`ax`); the roster also covers `claude`, `codex`, `cursor`, and `devin` when their CLIs are installed.
248
+
249
+ Show the roster and the current default:
250
+
251
+ ```bash
252
+ atris engine
253
+ ```
254
+
255
+ Flip the default for this workspace (persists to `.atris/engine.json`):
256
+
257
+ ```bash
258
+ atris engine cursor
259
+ atris engine reset # back to the house default (atris-fast)
260
+ ```
261
+
262
+ Ride a different engine for one run only, without changing the default. `--engine <name>` works on `mission run`, `autopilot`, and `run`. Per-run flags and `ATRIS_RUNNER_PROFILE` always beat the saved file.
263
+
264
+ ```bash
265
+ atris mission run "fix the flaky login test" --engine codex
266
+ ```
267
+
268
+ ### Fleet Flight
269
+
270
+ `atris mission run --fleet` staffs every idle installed engine on the board's claimable safe-lane tasks — one mission per task, one worktree per engine. Builds run in parallel; arrivals land serially with rebase-before-ship, so a rebase conflict pauses that landing (never auto-resolved) and keeps its worktree instead of orphaning work. Each flight writes a receipt to `atris/runs/fleet-<stamp>.json`.
271
+
272
+ ```bash
273
+ atris mission run --fleet --slots 3 # staff up to 3 engines
274
+ atris mission run --fleet --dry-run # preview the staffing only
275
+ ```
276
+
235
277
  ## Verifiable Feedback Loop
236
278
 
237
279
  Under the hood, Atris can keep score on real repo work.
package/ax CHANGED
@@ -862,6 +862,19 @@ function formatAtrisGoal(goal) {
862
862
  ].filter(Boolean).join('\n');
863
863
  }
864
864
 
865
+ function formatGoalStatusLine(goal, options = {}) {
866
+ if (!goal || !goal.objective) return '';
867
+ const status = String(goal.mission_status || '').toLowerCase();
868
+ if (!['active', 'running', 'planning', 'in_progress', 'ready'].includes(status)) return '';
869
+ const elapsed = goalElapsedSeconds(goal);
870
+ const objective = String(goal.objective).length > 64
871
+ ? `${String(goal.objective).slice(0, 61)}...`
872
+ : String(goal.objective);
873
+ const parts = [`goal: ${objective}`, status];
874
+ if (elapsed !== null) parts.push(`${formatSeconds(elapsed)} in`);
875
+ return paint(`· ${parts.join(' · ')}`, [ANSI.muted], options);
876
+ }
877
+
865
878
  function writeAtrisGoalBanner(output, cwd = process.cwd()) {
866
879
  const text = formatAtrisGoal(currentAtrisGoal(cwd));
867
880
  if (!text) return false;
@@ -937,28 +950,176 @@ function resetMarkdownState(state) {
937
950
  state.markdownMode = 'normal';
938
951
  state.markdownBuffer = '';
939
952
  state.markdownCarry = '';
953
+ state.markdownFence = false;
954
+ state.markdownTable = [];
955
+ state.markdownLine = '';
956
+ state.markdownLineMode = 'start';
940
957
  }
941
958
 
942
959
  function ensureMarkdownState(state) {
943
960
  if (!state.markdownMode) state.markdownMode = 'normal';
944
961
  if (typeof state.markdownBuffer !== 'string') state.markdownBuffer = '';
945
962
  if (typeof state.markdownCarry !== 'string') state.markdownCarry = '';
963
+ if (typeof state.markdownFence !== 'boolean') state.markdownFence = false;
964
+ if (!Array.isArray(state.markdownTable)) state.markdownTable = [];
965
+ if (typeof state.markdownLine !== 'string') state.markdownLine = '';
966
+ if (!state.markdownLineMode) state.markdownLineMode = 'start';
967
+ }
968
+
969
+ function renderInlineMarkdown(text, options = {}) {
970
+ let rendered = String(text || '');
971
+ const codeSpans = [];
972
+ rendered = rendered.replace(/`([^`\n]+)`/g, (_, code) => {
973
+ const token = `\u0000CODE${codeSpans.length}\u0000`;
974
+ codeSpans.push(code);
975
+ return token;
976
+ });
977
+ rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, (_, value) => paint(value, [ANSI.bold], options));
978
+ rendered = rendered.replace(/__([^_\n]+)__/g, (_, value) => paint(value, [ANSI.bold], options));
979
+ rendered = rendered.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => paint(codeSpans[Number(index)] || '', [ANSI.accent], options));
980
+ return rendered;
946
981
  }
947
982
 
983
+ function renderMarkdownTable(lines, options = {}) {
984
+ const rows = [];
985
+ for (const line of lines) {
986
+ const trimmed = String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '');
987
+ const cells = trimmed.split('|').map(cell => cell.trim());
988
+ if (cells.length && cells.every(cell => /^:?-{2,}:?$/.test(cell))) continue;
989
+ rows.push(cells);
990
+ }
991
+ if (!rows.length) return '';
992
+ const width = Math.max(...rows.map(row => row.length));
993
+ const plain = value => String(value || '').replace(/\x1b\[[0-9;]*m/g, '');
994
+ const rendered = rows.map(row => row.map(cell => renderInlineMarkdown(cell, options)));
995
+ const colWidths = Array.from({ length: width }, (_, col) => (
996
+ Math.max(...rendered.map(row => plain(row[col] || '').length))
997
+ ));
998
+ const sep = paint('│', [ANSI.muted], options);
999
+ return rendered.map((row, rowIndex) => {
1000
+ const cells = Array.from({ length: width }, (_, col) => {
1001
+ const cell = row[col] || '';
1002
+ const pad = ' '.repeat(Math.max(0, colWidths[col] - plain(cell).length));
1003
+ const value = `${cell}${pad}`;
1004
+ return rowIndex === 0 ? paint(plain(value), [ANSI.bold], options) : value;
1005
+ });
1006
+ return ` ${cells.join(` ${sep} `)}`;
1007
+ }).join('\n') + '\n';
1008
+ }
1009
+
1010
+ function flushMarkdownTable(state, options = {}) {
1011
+ if (!state.markdownTable.length) return '';
1012
+ const table = renderMarkdownTable(state.markdownTable, options);
1013
+ state.markdownTable = [];
1014
+ return table;
1015
+ }
1016
+
1017
+ function renderMarkdownLine(state, line, options = {}) {
1018
+ const fenceMatch = /^\s*```/.test(line);
1019
+ if (fenceMatch) {
1020
+ state.markdownFence = !state.markdownFence;
1021
+ const label = line.trim().slice(3).trim();
1022
+ if (state.markdownFence && label) return `${paint(label, [ANSI.muted], options)}\n`;
1023
+ return '';
1024
+ }
1025
+ if (state.markdownFence) {
1026
+ return ` ${paint(line, [ANSI.accent], options)}\n`;
1027
+ }
1028
+ if (/^\s*\|/.test(line)) {
1029
+ state.markdownTable.push(line);
1030
+ return '';
1031
+ }
1032
+ let out = flushMarkdownTable(state, options);
1033
+ const header = line.match(/^(#{1,6})\s+(.*)$/);
1034
+ if (header) {
1035
+ const codes = header[1].length <= 2 ? [ANSI.bold, ANSI.accent] : [ANSI.bold];
1036
+ return `${out}${paint(renderInlineMarkdown(header[2], options).replace(/\x1b\[[0-9;]*m/g, ''), codes, options)}\n`;
1037
+ }
1038
+ if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
1039
+ return `${out}${paint('─'.repeat(36), [ANSI.muted], options)}\n`;
1040
+ }
1041
+ const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
1042
+ if (bullet) {
1043
+ return `${out}${bullet[1]}${paint('•', [ANSI.bold], options)} ${renderInlineMarkdown(bullet[2], options)}\n`;
1044
+ }
1045
+ const quote = line.match(/^\s*>\s?(.*)$/);
1046
+ if (quote) {
1047
+ return `${out}${paint('│', [ANSI.muted], options)} ${paint(renderInlineMarkdown(quote[1], options).replace(/\x1b\[[0-9;]*m/g, ''), [ANSI.muted], options)}\n`;
1048
+ }
1049
+ return `${out}${renderInlineMarkdown(line, options)}\n`;
1050
+ }
1051
+
1052
+ // Hybrid streaming renderer. Prose streams character-live through the inline
1053
+ // state machine (bold/code style as delimiters close), so chat feels
1054
+ // immediate. Structure-shaped lines (tables, fences, headers, bullets,
1055
+ // quotes) buffer to the line boundary and render as blocks, because they
1056
+ // cannot be styled until the whole line exists. The first character of each
1057
+ // line decides which lane the line takes.
1058
+ const MARKDOWN_BLOCK_LINE_START = new Set(['|', '#', '`', '>', '-', '*']);
1059
+
948
1060
  function renderStreamingMarkdown(state, text, options = {}) {
949
1061
  ensureMarkdownState(state);
950
- const input = `${state.markdownCarry || ''}${String(text || '')}`;
951
- state.markdownCarry = '';
1062
+ const input = String(text || '');
952
1063
  let out = '';
953
1064
 
954
1065
  for (let i = 0; i < input.length;) {
955
1066
  const char = input[i];
956
- const next = input[i + 1];
957
1067
 
1068
+ // Structure lane: buffer the whole line, render at the newline.
1069
+ if (state.markdownFence || state.markdownLineMode === 'block') {
1070
+ if (char === '\n') {
1071
+ out += renderMarkdownLine(state, state.markdownLine, options);
1072
+ state.markdownLine = '';
1073
+ state.markdownLineMode = 'start';
1074
+ } else {
1075
+ state.markdownLine += char;
1076
+ }
1077
+ i += 1;
1078
+ continue;
1079
+ }
1080
+
1081
+ // Line start: decide which lane this line takes.
1082
+ if (state.markdownLineMode === 'start') {
1083
+ if (char === '\n') {
1084
+ out += flushMarkdownTable(state, options);
1085
+ out += '\n';
1086
+ i += 1;
1087
+ continue;
1088
+ }
1089
+ if (MARKDOWN_BLOCK_LINE_START.has(char)) {
1090
+ state.markdownLineMode = 'block';
1091
+ state.markdownLine = char;
1092
+ i += 1;
1093
+ continue;
1094
+ }
1095
+ out += flushMarkdownTable(state, options);
1096
+ state.markdownLineMode = 'prose';
1097
+ continue;
1098
+ }
1099
+
1100
+ // Prose lane: the inline state machine, character-live.
1101
+ const next = input[i + 1];
958
1102
  if (state.markdownMode === 'normal') {
1103
+ if (state.markdownCarry === '*') {
1104
+ state.markdownCarry = '';
1105
+ if (char === '*') {
1106
+ state.markdownMode = 'bold';
1107
+ state.markdownBuffer = '';
1108
+ i += 1;
1109
+ continue;
1110
+ }
1111
+ out += '*';
1112
+ }
1113
+ if (char === '\n') {
1114
+ out += '\n';
1115
+ state.markdownLineMode = 'start';
1116
+ i += 1;
1117
+ continue;
1118
+ }
959
1119
  if (char === '*' && next === undefined) {
960
1120
  state.markdownCarry = '*';
961
- break;
1121
+ i += 1;
1122
+ continue;
962
1123
  }
963
1124
  if (char === '*' && next === '*') {
964
1125
  state.markdownMode = 'bold';
@@ -978,23 +1139,52 @@ function renderStreamingMarkdown(state, text, options = {}) {
978
1139
  }
979
1140
 
980
1141
  if (state.markdownMode === 'bold') {
1142
+ if (char === '\n') {
1143
+ out += `**${state.markdownBuffer}\n`;
1144
+ state.markdownMode = 'normal';
1145
+ state.markdownBuffer = '';
1146
+ state.markdownLineMode = 'start';
1147
+ i += 1;
1148
+ continue;
1149
+ }
981
1150
  if (char === '*' && next === undefined) {
982
1151
  state.markdownCarry = '*';
983
- break;
1152
+ i += 1;
1153
+ continue;
984
1154
  }
985
- if (char === '*' && next === '*') {
1155
+ if (char === '*' && (next === '*' || state.markdownCarry === '*')) {
986
1156
  out += paint(state.markdownBuffer, [ANSI.bold], options);
987
1157
  state.markdownMode = 'normal';
988
1158
  state.markdownBuffer = '';
989
- i += 2;
1159
+ state.markdownCarry = '';
1160
+ i += next === '*' ? 2 : 1;
990
1161
  continue;
991
1162
  }
1163
+ if (state.markdownCarry === '*') {
1164
+ state.markdownCarry = '';
1165
+ if (char === '*') {
1166
+ out += paint(state.markdownBuffer, [ANSI.bold], options);
1167
+ state.markdownMode = 'normal';
1168
+ state.markdownBuffer = '';
1169
+ i += 1;
1170
+ continue;
1171
+ }
1172
+ state.markdownBuffer += '*';
1173
+ }
992
1174
  state.markdownBuffer += char;
993
1175
  i += 1;
994
1176
  continue;
995
1177
  }
996
1178
 
997
1179
  if (state.markdownMode === 'code') {
1180
+ if (char === '\n') {
1181
+ out += `\`${state.markdownBuffer}\n`;
1182
+ state.markdownMode = 'normal';
1183
+ state.markdownBuffer = '';
1184
+ state.markdownLineMode = 'start';
1185
+ i += 1;
1186
+ continue;
1187
+ }
998
1188
  if (char === '`') {
999
1189
  out += paint(state.markdownBuffer, [ANSI.accent], options);
1000
1190
  state.markdownMode = 'normal';
@@ -1013,12 +1203,27 @@ function renderStreamingMarkdown(state, text, options = {}) {
1013
1203
 
1014
1204
  function flushStreamingMarkdown(state, output) {
1015
1205
  ensureMarkdownState(state);
1016
- let out = state.markdownCarry || '';
1206
+ let out = '';
1207
+ if (state.markdownLine) {
1208
+ if (/^\s*```/.test(state.markdownLine)) {
1209
+ // A fence marker with no trailing newline is a block boundary, not code.
1210
+ state.markdownFence = !state.markdownFence;
1211
+ } else if (state.markdownFence) {
1212
+ out += ` ${paint(state.markdownLine, [ANSI.accent], output)}`;
1213
+ } else {
1214
+ out += renderMarkdownLine(state, state.markdownLine, output).replace(/\n$/, '');
1215
+ }
1216
+ state.markdownLine = '';
1217
+ }
1218
+ if (state.markdownCarry) {
1219
+ out += state.markdownCarry;
1220
+ }
1017
1221
  if (state.markdownMode === 'bold' && state.markdownBuffer) {
1018
1222
  out += paint(state.markdownBuffer, [ANSI.bold], output);
1019
1223
  } else if (state.markdownMode === 'code' && state.markdownBuffer) {
1020
1224
  out += paint(state.markdownBuffer, [ANSI.accent], output);
1021
1225
  }
1226
+ out += flushMarkdownTable(state, output);
1022
1227
  resetMarkdownState(state);
1023
1228
  if (out) output.write(out);
1024
1229
  return out;
@@ -1476,6 +1681,99 @@ function removeStoredApproval(ref, options = {}) {
1476
1681
  return record;
1477
1682
  }
1478
1683
 
1684
+ // Local turns stage side effects as signed artifacts in the workspace's
1685
+ // atris/approvals/ (atris.local_approval.v1), a different store from
1686
+ // ~/.atris/ax-approvals.json. ax --approve redeems them through the local
1687
+ // backend's approvals/execute endpoint, which re-signs with explicit user
1688
+ // approval and runs the gated executor.
1689
+ function workspaceApprovalsDir(cwd) {
1690
+ return path.join(cwd || process.cwd(), 'atris', 'approvals');
1691
+ }
1692
+
1693
+ function listWorkspaceApprovals(cwd) {
1694
+ const dir = workspaceApprovalsDir(cwd);
1695
+ let names = [];
1696
+ try {
1697
+ names = fs.readdirSync(dir).filter(name => name.endsWith('.json'));
1698
+ } catch {
1699
+ return [];
1700
+ }
1701
+ const records = [];
1702
+ for (const name of names) {
1703
+ const filePath = path.join(dir, name);
1704
+ try {
1705
+ const record = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1706
+ if (record && record.schema === 'atris.local_approval.v1') {
1707
+ records.push({ path: filePath, record, mtimeMs: fs.statSync(filePath).mtimeMs });
1708
+ }
1709
+ } catch {
1710
+ // Unreadable artifact: skip, never block the turn.
1711
+ }
1712
+ }
1713
+ return records;
1714
+ }
1715
+
1716
+ function findWorkspaceApproval(ref, cwd) {
1717
+ const target = String(ref || '').trim();
1718
+ if (!target) return null;
1719
+ const matches = listWorkspaceApprovals(cwd)
1720
+ .filter(item => String(item.record.approval_id || '').startsWith(target));
1721
+ if (matches.length !== 1) return null;
1722
+ return matches[0];
1723
+ }
1724
+
1725
+ function pendingWorkspaceApprovalsSince(cwd, sinceMs) {
1726
+ return listWorkspaceApprovals(cwd).filter(item => (
1727
+ item.mtimeMs >= sinceMs
1728
+ && String(item.record.status || '') === 'pending'
1729
+ && !(item.record.decision && item.record.decision.approved)
1730
+ ));
1731
+ }
1732
+
1733
+ async function approveWorkspaceApproval(ref, options = {}) {
1734
+ const cwd = options.cwd || process.cwd();
1735
+ const found = findWorkspaceApproval(ref, cwd);
1736
+ if (!found) throw new Error(`Pending approval not found: ${ref}`);
1737
+ const output = options.output || process.stdout;
1738
+ const { record } = found;
1739
+ const startedAt = Date.now();
1740
+ const payload = record.payload && typeof record.payload === 'object' ? record.payload : {};
1741
+ output.write(`${formatAuxRow('id', record.approval_id, output)}\n`);
1742
+ if (record.summary) output.write(`${formatAuxRow('action', record.summary, output)}\n`);
1743
+ if (payload.command) output.write(`${formatAuxRow('command', payload.command, output)}\n`);
1744
+ output.write('Accepted.\n');
1745
+ const response = await postJson(APPROVAL_EXECUTE_PATH, {
1746
+ model: modelForMode(options.mode || 'fast'),
1747
+ approval_request: {
1748
+ action_type: record.action_type,
1749
+ approval_id: record.approval_id,
1750
+ summary: record.summary,
1751
+ payload: { ...payload, workspace: payload.workspace || cwd },
1752
+ },
1753
+ }, { route: 'local' });
1754
+ const execution = response && response.data && typeof response.data === 'object' ? response.data : {};
1755
+ const executed = Boolean(execution.execution && execution.execution.executed) || execution.status === 'executed';
1756
+ const statusText = executed
1757
+ ? 'Done.'
1758
+ : `Not executed: ${String(execution.status || response.error || `HTTP ${response.status || 0}`)}`;
1759
+ output.write(`${formatAuxRow(executed ? 'done' : 'wait', statusText, output)}\n`);
1760
+ try {
1761
+ const spent = { ...record, status: executed ? 'executed' : record.status, executed_at: executed ? new Date().toISOString() : undefined };
1762
+ fs.writeFileSync(found.path, `${JSON.stringify(spent, null, 2)}\n`);
1763
+ } catch {
1764
+ // Bookkeeping only; the backend holds replay protection.
1765
+ }
1766
+ output.write(`${formatDoneLine(Date.now() - startedAt)}\n`);
1767
+ if (!response.ok && !executed) process.exitCode = 1;
1768
+ return { response, executed };
1769
+ }
1770
+
1771
+ function writeWorkspaceApprovalHints(cwd, sinceMs, output) {
1772
+ for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
1773
+ output.write(`${formatAuxRow('approve', formatApprovalCommand(item.record.approval_id), output)}\n`);
1774
+ }
1775
+ }
1776
+
1479
1777
  function formatStoredApprovals(store, options = {}) {
1480
1778
  const approvals = Array.isArray(store && store.approvals) ? store.approvals : [];
1481
1779
  if (!approvals.length) return 'No pending approvals.';
@@ -1762,8 +2060,30 @@ function writeAuxLine(state, output, line) {
1762
2060
  state.needsBullet = true;
1763
2061
  }
1764
2062
 
2063
+ // Turns beyond the recent window used to fall off a cliff: turn 9 of a chat
2064
+ // silently forgot turn 1 and never said so. Evicted turns now compress into
2065
+ // a digest that rides as the first previous message, so long chats keep the
2066
+ // thread of the whole conversation. AX_NO_COMPACT=1 restores the bare window.
2067
+ const HISTORY_DIGEST_MAX_CHARS = 2400;
2068
+
2069
+ function compactedHistoryDigest(evicted) {
2070
+ const lines = [];
2071
+ for (const turn of evicted) {
2072
+ const who = turn && turn.role === 'assistant' ? 'atris' : 'user';
2073
+ const text = String((turn && turn.content) || '').replace(/\s+/g, ' ').trim().slice(0, 160);
2074
+ if (text) lines.push(`${who}: ${text}`);
2075
+ }
2076
+ let digest = lines.join('\n');
2077
+ if (digest.length > HISTORY_DIGEST_MAX_CHARS) {
2078
+ digest = digest.slice(digest.length - HISTORY_DIGEST_MAX_CHARS);
2079
+ digest = digest.slice(digest.indexOf('\n') + 1);
2080
+ }
2081
+ return digest;
2082
+ }
2083
+
1765
2084
  function structuredHistory(history, limit = 8) {
1766
- return (Array.isArray(history) ? history : [])
2085
+ const rows = Array.isArray(history) ? history : [];
2086
+ const recent = rows
1767
2087
  .slice(-limit)
1768
2088
  .map(turn => {
1769
2089
  const message = {
@@ -1776,6 +2096,16 @@ function structuredHistory(history, limit = 8) {
1776
2096
  return message;
1777
2097
  })
1778
2098
  .filter(turn => turn.content.trim());
2099
+ if (rows.length <= limit || process.env.AX_NO_COMPACT === '1') return recent;
2100
+ const digest = compactedHistoryDigest(rows.slice(0, rows.length - limit));
2101
+ if (!digest) return recent;
2102
+ return [
2103
+ {
2104
+ role: 'user',
2105
+ content: `[conversation so far, compacted from ${rows.length - limit} earlier turns]\n${digest}`,
2106
+ },
2107
+ ...recent,
2108
+ ];
1779
2109
  }
1780
2110
 
1781
2111
  function latestPendingTaskPreview(history = []) {
@@ -2300,6 +2630,7 @@ async function chat(options = {}) {
2300
2630
  const logger = createRunLogger({ cwd, mode, kind: 'play', output: baseOutput });
2301
2631
  const output = logger ? logger.output : baseOutput;
2302
2632
  const history = [];
2633
+ let lastCompactionNotice = 0;
2303
2634
  const conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
2304
2635
 
2305
2636
  output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
@@ -2444,6 +2775,12 @@ async function chat(options = {}) {
2444
2775
  if (!(await ensureRuntimeReady(turnRoute))) return false;
2445
2776
  const turnFunction = options.turnFunction || turnFunctionForMode(mode);
2446
2777
  const turnId = options.turnId || createTurnId();
2778
+ const turnStartedAt = Date.now();
2779
+ const compactedTurns = process.env.AX_NO_COMPACT === '1' ? 0 : Math.max(0, history.length - 8);
2780
+ if (compactedTurns > 0 && compactedTurns !== lastCompactionNotice) {
2781
+ lastCompactionNotice = compactedTurns;
2782
+ output.write(`${paint(`· context: ${compactedTurns} earlier turn${compactedTurns === 1 ? '' : 's'} compacted into a digest`, [ANSI.muted], output)}\n`);
2783
+ }
2447
2784
  const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId, turnId });
2448
2785
  if (result.output && !result.output.endsWith('\n')) output.write('\n');
2449
2786
  const approvalExecution = normalizeMode(mode) === 'code-fast'
@@ -2453,7 +2790,11 @@ async function chat(options = {}) {
2453
2790
  output,
2454
2791
  ...(options.postApproval ? { postApproval: options.postApproval } : {}),
2455
2792
  });
2456
- output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n\n`);
2793
+ if (!options.turnFunction) writeWorkspaceApprovalHints(cwd, turnStartedAt, output);
2794
+ output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n`);
2795
+ const goalLine = options.turnFunction ? '' : formatGoalStatusLine(currentAtrisGoal(cwd), output);
2796
+ if (goalLine) output.write(`${goalLine}\n`);
2797
+ output.write('\n');
2457
2798
  const receipt = receiptFromState(result);
2458
2799
  const approvalRequest = approvalExecution
2459
2800
  ? (
@@ -3365,7 +3706,11 @@ async function main() {
3365
3706
  }
3366
3707
 
3367
3708
  if (approveId) {
3368
- await approveStoredApproval(approveId, { mode, output: process.stdout });
3709
+ if (findStoredApproval(approveId)) {
3710
+ await approveStoredApproval(approveId, { mode, output: process.stdout });
3711
+ } else {
3712
+ await approveWorkspaceApproval(approveId, { cwd: process.cwd(), mode, output: process.stdout });
3713
+ }
3369
3714
  return;
3370
3715
  }
3371
3716
 
@@ -3383,6 +3728,7 @@ async function main() {
3383
3728
  console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
3384
3729
  console.log('');
3385
3730
  maybeWriteAtrisGoalBanner(process.stdout, process.cwd());
3731
+ const turnStartedAt = Date.now();
3386
3732
  const result = await turnFunctionForMode(mode)(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
3387
3733
  const receipt = receiptFromState(result);
3388
3734
  const approvalRequest = normalizeMode(mode) === 'code-fast' ? null : approvalRequestFromReceipt(receipt);
@@ -3390,6 +3736,9 @@ async function main() {
3390
3736
  ? persistPendingApproval(receipt, approvalRequest, { cwd: process.cwd(), mode })
3391
3737
  : null;
3392
3738
  if (approvalRecord) console.log(formatAuxRow('approve', formatApprovalCommand(approvalRecord.id), process.stdout));
3739
+ // A one-shot has no next turn, so "reply yes to approve" is a dead end;
3740
+ // surface the exact redeem command for anything the turn staged on disk.
3741
+ writeWorkspaceApprovalHints(process.cwd(), turnStartedAt, process.stdout);
3393
3742
  console.log('');
3394
3743
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
3395
3744
  } catch (error) {
@@ -3417,6 +3766,10 @@ module.exports = {
3417
3766
  approvalRequestFromReceipt,
3418
3767
  approvalStorePath,
3419
3768
  approveStoredApproval,
3769
+ approveWorkspaceApproval,
3770
+ findWorkspaceApproval,
3771
+ listWorkspaceApprovals,
3772
+ pendingWorkspaceApprovalsSince,
3420
3773
  backendBaseUrl,
3421
3774
  backendStartCommand,
3422
3775
  backendUrl,