@lumoai/cli 1.32.0 → 1.34.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.
@@ -6,6 +6,17 @@ const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const resolve_bound_task_1 = require("../lib/resolve-bound-task");
8
8
  const sanitize_1 = require("../lib/sanitize");
9
+ const open_crossings_1 = require("../lib/open-crossings");
10
+ /** One-line a possibly-multiline crossing detail and cap it so the safety block
11
+ * stays a glance, never a wall (it must not overshadow the criteria). */
12
+ const CROSSING_DETAIL_CAP = 160;
13
+ function oneLineDetail(detail) {
14
+ const firstLine = detail.split('\n')[0] ?? '';
15
+ const clean = (0, sanitize_1.sanitizeField)(firstLine).trim();
16
+ return clean.length > CROSSING_DETAIL_CAP
17
+ ? `${clean.slice(0, CROSSING_DETAIL_CAP)}…`
18
+ : clean;
19
+ }
9
20
  const REASON_TAIL = 400;
10
21
  function tail(s, max) {
11
22
  return s.length > max ? `…${s.slice(-max)}` : s;
@@ -16,7 +27,7 @@ function tail(s, max) {
16
27
  * glyph in front — ✓ pass, ✗ fail, ○ no verdict yet — so REVIEW_ADDED
17
28
  * provenance stays explicitly visible in every row.
18
29
  */
19
- function formatTaskStatus(data) {
30
+ function formatTaskStatus(data, extras = {}) {
20
31
  const lines = [];
21
32
  const t = data.task;
22
33
  lines.push(`${t.identifier} ${(0, sanitize_1.sanitizeField)(t.title)}`);
@@ -30,6 +41,7 @@ function formatTaskStatus(data) {
30
41
  if (data.criteria.length === 0) {
31
42
  lines.push('');
32
43
  lines.push(`No acceptance criteria on ${t.identifier} — draft 3–7 and submit with lumo task criteria set ${t.identifier} --file <criteria.json>`);
44
+ pushOpenCrossings(lines, extras);
33
45
  return lines.join('\n') + '\n';
34
46
  }
35
47
  lines.push('');
@@ -46,6 +58,15 @@ function formatTaskStatus(data) {
46
58
  if (c.checkpointer) {
47
59
  lines.push(` ↳ check: ${(0, sanitize_1.sanitizeField)(c.checkpointer)}`);
48
60
  }
61
+ // LUM-465: agent-drafted human-judging guidance, rendered as an indented
62
+ // block under a HUMAN criterion (multi-line steps stay aligned).
63
+ if (c.judgeSteps) {
64
+ const judgeLines = (0, sanitize_1.sanitizeField)(c.judgeSteps).split('\n');
65
+ lines.push(` ↳ judge: ${judgeLines[0]}`);
66
+ for (const jl of judgeLines.slice(1)) {
67
+ lines.push(` ${jl}`);
68
+ }
69
+ }
49
70
  const v = c.latestVerdict;
50
71
  if (v == null) {
51
72
  lines.push(' (no verdict yet)');
@@ -61,6 +82,11 @@ function formatTaskStatus(data) {
61
82
  ? ` · ${(0, sanitize_1.sanitizeField)(v.evidencePointer)}`
62
83
  : '';
63
84
  lines.push(` ✓ ${v.verdict}@r${v.round}${evidencePart}`);
85
+ // LUM-457: a pass that vouches for a pre-edit version of the criterion —
86
+ // render-only downgrade, the criterion still counts met.
87
+ if (c.verdictStale || c.checkMismatch) {
88
+ lines.push(' ⚠ pre-edit version — criterion changed since this check; re-run `lumo verify` to re-confirm');
89
+ }
64
90
  }
65
91
  }
66
92
  if (data.verificationHistory.length > 0) {
@@ -108,8 +134,50 @@ function formatTaskStatus(data) {
108
134
  : 'Remaining criteria are HUMAN-only — finish the work and hand off for human review.');
109
135
  }
110
136
  }
137
+ pushOpenCrossings(lines, extras);
111
138
  return lines.join('\n') + '\n';
112
139
  }
140
+ /**
141
+ * Append the OPEN boundary-crossings safety block (LUM-448) — a count, one line
142
+ * per crossing with its severity + category + clipped detail, and a pointer to
143
+ * the human-only web disposition panel. Trailing (after the criteria) and
144
+ * silent when there are none, so it never overshadows the acceptance contract.
145
+ * Read-only awareness: the line points at the web UI; it offers no way to clear
146
+ * a crossing from the terminal (LUM-426/435/422).
147
+ */
148
+ function pushOpenCrossings(lines, extras) {
149
+ const open = extras.openCrossings ?? [];
150
+ if (open.length === 0)
151
+ return;
152
+ lines.push('');
153
+ lines.push(`⚠ Open boundary crossings (${open.length} undispositioned):`);
154
+ for (const c of open) {
155
+ const detail = oneLineDetail(c.detail);
156
+ const tail = detail ? ` — ${detail}` : '';
157
+ lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
158
+ lines.push(` ${formatAttribution(c.attribution)}`);
159
+ }
160
+ lines.push(' Disposition is human-only in the web acceptance panel:');
161
+ if (extras.dispositionUrl) {
162
+ lines.push(` ${extras.dispositionUrl}`);
163
+ }
164
+ }
165
+ /**
166
+ * Compact attribution line for an open crossing (LUM-469): which model + which
167
+ * agent/session committed it, so a reviewer can audit who/what crossed from the
168
+ * terminal. Every dimension that the server couldn't resolve renders `unknown`
169
+ * — never a fabricated value. The session UUID is clipped to a correlatable
170
+ * prefix; the worktree branch (which branch) is appended to the agent when known.
171
+ */
172
+ function formatAttribution(a) {
173
+ const agent = a.agent
174
+ ? a.worktreeBranch
175
+ ? `${a.agent}/${a.worktreeBranch}`
176
+ : a.agent
177
+ : 'unknown';
178
+ const session = a.sessionId ? a.sessionId.slice(0, 8) : 'unknown';
179
+ return `↳ by model=${a.model ?? 'unknown'} · agent=${agent} · session=${session}`;
180
+ }
113
181
  /**
114
182
  * `lumo task status [task] [--json]` — the agent's read-only self-check
115
183
  * (LUM-344): contract + latest verdicts + verification history + next
@@ -126,8 +194,7 @@ async function taskStatus(identifier, options = {}) {
126
194
  const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
127
195
  let taskId = identifier;
128
196
  if (!taskId) {
129
- taskId =
130
- (await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(base, creds.token)) ?? undefined;
197
+ taskId = (await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(base, creds.token)) ?? undefined;
131
198
  if (!taskId) {
132
199
  console.error('Error: no task given and this session is not bound to one.\n' +
133
200
  'Run `lumo task status <LUM-N>`, or bind first with `lumo session attach <LUM-N>`.');
@@ -156,11 +223,21 @@ async function taskStatus(identifier, options = {}) {
156
223
  return 1;
157
224
  }
158
225
  const data = (await res.json());
226
+ // Read-only awareness (LUM-448): surface the task's OPEN boundary crossings
227
+ // via the existing LUM-435 endpoint. Best-effort — fetchOpenCrossings already
228
+ // swallows failures to an empty list, so this supplementary safety signal can
229
+ // never block the primary acceptance status. The resolved taskId (the
230
+ // identifier the status was fetched for) is the key here.
231
+ const openCrossings = await (0, open_crossings_1.fetchOpenCrossings)(base, creds.token, taskId);
159
232
  if (options.json) {
160
233
  // JSON.stringify escapes control chars (…), so the payload is safe
161
234
  // to emit raw — and consumers get byte-faithful server data.
162
- process.stdout.write(JSON.stringify(data, null, 2) + '\n');
235
+ // The open crossings ride alongside as an additive field (count = length).
236
+ process.stdout.write(JSON.stringify({ ...data, openCrossings }, null, 2) + '\n');
163
237
  return;
164
238
  }
165
- process.stdout.write(formatTaskStatus(data));
239
+ process.stdout.write(formatTaskStatus(data, {
240
+ openCrossings,
241
+ dispositionUrl: (0, open_crossings_1.dispositionUrl)(base, creds.workspaceSlug ?? 'lumo', data.task.identifier),
242
+ }));
166
243
  }
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCrossingReminder = formatCrossingReminder;
4
+ exports.openCrossingReminder = openCrossingReminder;
5
+ const resolve_bound_task_1 = require("../../lib/resolve-bound-task");
6
+ const sanitize_1 = require("../../lib/sanitize");
7
+ const open_crossings_1 = require("../../lib/open-crossings");
8
+ /**
9
+ * Build the wrap-up reminder for a task's OPEN boundary crossings (LUM-448).
10
+ * Returns the reminder string when there is ≥1 open crossing, and `null` when
11
+ * there are none — the caller prints nothing on null, so a clean task makes NO
12
+ * noise at wrap time. Read-only awareness: the reminder points at the
13
+ * human-only web disposition panel and offers no way to clear a crossing from
14
+ * the terminal (LUM-426/435/422).
15
+ */
16
+ function formatCrossingReminder(taskIdentifier, open, url) {
17
+ if (open.length === 0)
18
+ return null;
19
+ const n = open.length;
20
+ const lines = [
21
+ `⚠ ${n} open boundary crossing${n === 1 ? '' : 's'} on ${taskIdentifier} still undispositioned:`,
22
+ ];
23
+ for (const c of open) {
24
+ lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}`);
25
+ }
26
+ lines.push(` Review & disposition (web + human-only): ${url}`);
27
+ return lines.join('\n') + '\n';
28
+ }
29
+ /**
30
+ * Resolve the session's bound task and surface its OPEN boundary crossings as a
31
+ * wrap-up reminder (LUM-448), or `null` when the session is unbound or nothing
32
+ * is open. Pure read — `fetchOpenCrossings` hits only the LUM-435 GET endpoint
33
+ * and there is no disposition write path here.
34
+ */
35
+ async function openCrossingReminder(creds) {
36
+ const taskIdentifier = await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(creds.apiUrl, creds.token);
37
+ if (!taskIdentifier)
38
+ return null;
39
+ const open = await (0, open_crossings_1.fetchOpenCrossings)(creds.apiUrl, creds.token, taskIdentifier);
40
+ return formatCrossingReminder(taskIdentifier, open, (0, open_crossings_1.dispositionUrl)(creds.apiUrl, creds.workspaceSlug, taskIdentifier));
41
+ }
@@ -44,7 +44,6 @@ const auth_logout_1 = require("./commands/auth-logout");
44
44
  const whoami_1 = require("./commands/whoami");
45
45
  const hook_1 = require("./commands/hook");
46
46
  const session_attach_1 = require("./commands/session-attach");
47
- const session_detach_1 = require("./commands/session-detach");
48
47
  const session_status_1 = require("./commands/session-status");
49
48
  const session_wrap_1 = require("./commands/session-wrap");
50
49
  const next_1 = require("./commands/next");
@@ -110,6 +109,7 @@ const doc_sync_1 = require("./commands/doc-sync");
110
109
  const doc_update_1 = require("./commands/doc-update");
111
110
  const doc_show_1 = require("./commands/doc-show");
112
111
  const doc_diff_1 = require("./commands/doc-diff");
112
+ const doc_rebuild_source_1 = require("./commands/doc-rebuild-source");
113
113
  const doc_section_edit_1 = require("./commands/doc-section-edit");
114
114
  const doc_list_1 = require("./commands/doc-list");
115
115
  const doc_delete_1 = require("./commands/doc-delete");
@@ -239,17 +239,12 @@ const session = program
239
239
  .description('Manage per-terminal coding-session context');
240
240
  session
241
241
  .command('attach <identifier>')
242
- .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. Sets Session.taskId server-side and re-tags untagged hook events. If the session is already bound to a different task, confirms before overwriting (use --force to skip).')
243
- .option('--force', 'Overwrite an existing binding to a different task without confirmation (skips the [y/N] prompt).')
244
- .action(wrap((identifier, options) => (0, session_attach_1.sessionAttach)(identifier, options)));
242
+ .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. The binding is a lifetime lock: re-attaching to the same task is a no-op, attaching to a different task is refused start a new session for a different task.')
243
+ .action(wrap(identifier => (0, session_attach_1.sessionAttach)(identifier)));
245
244
  session
246
245
  .command('status')
247
246
  .description('Show the task currently bound to this Claude Code session (or "no task" if none).')
248
247
  .action(wrap(() => (0, session_status_1.sessionStatus)()));
249
- session
250
- .command('detach')
251
- .description('Clear the task binding on the current Claude Code session. Past hook events keep their taskId; only future events become untagged.')
252
- .action(wrap(() => (0, session_detach_1.sessionDetach)()));
253
248
  session
254
249
  .command('wrap')
255
250
  .description("Session-end wrap-up: draft a progress comment from this session's turn summaries and post it to the bound task after confirmation.")
@@ -344,8 +339,9 @@ const taskComments = task
344
339
  .description('Inspect the full task comment thread');
345
340
  taskComments
346
341
  .command('list <identifier>')
347
- .description('List the full task comment thread')
348
- .action(wrap(id => (0, task_comment_list_1.taskCommentList)(id)));
342
+ .description('List the task comment thread (capped to the output budget; --full prints every comment)')
343
+ .option('--full', 'Print every comment, bypassing the output-token cap')
344
+ .action(wrap((id, opts) => (0, task_comment_list_1.taskCommentList)(id, opts)));
349
345
  const taskDeps = task
350
346
  .command('deps')
351
347
  .description('Task dependency edges — detected candidates + confirmed blockers');
@@ -714,6 +710,13 @@ doc
714
710
  .description('Compare the server-side markdown source against a local file. Exit 0 when byte-identical, 1 with a unified diff when divergent. Requires the doc to have a stored markdown source.')
715
711
  .requiredOption('--file <path>', 'Local markdown file to compare against')
716
712
  .action(wrap((reference, opts) => (0, doc_diff_1.docDiff)(reference, opts)));
713
+ doc
714
+ .command('rebuild-source <doc>')
715
+ .description("Regenerate the stored markdown source from the doc's HTML body using a lossless serializer (tables/rows/headings round-trip), re-enabling doc show --raw / diff / patch / append for a source-less doc. The rebuilt source is structure-guarded: any table/tr/heading shrink is rejected with 422 (no silent flattening) unless --allow-shrink is passed. A doc that already has a source is refused with 409 unless --force re-derives it (replacing a byte-faithful source with a serializer-derived one).")
716
+ .option('--allow-shrink', 'Commit even if the rebuilt source re-renders with fewer tables/rows/headings than the stored body (default: rejected with 422)')
717
+ .option('--force', 'Re-derive the source even when one already exists (default: refused with 409)')
718
+ .option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
719
+ .action(wrap((reference, opts) => (0, doc_rebuild_source_1.docRebuildSource)(reference, opts)));
717
720
  doc
718
721
  .command('list')
719
722
  .description('List documents visible to the current user')
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchOpenCrossings = fetchOpenCrossings;
4
+ exports.dispositionUrl = dispositionUrl;
5
+ const api_1 = require("./api");
6
+ const SEVERITY_RANK = {
7
+ HIGH: 3,
8
+ MEDIUM: 2,
9
+ LOW: 1,
10
+ };
11
+ /** Narrow the raw view's attribution to the terminal's read-only shape, every
12
+ * dimension defaulting to null (unknown) when absent or the wrong type. */
13
+ function normalizeAttribution(raw) {
14
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : null;
15
+ return {
16
+ workspaceMemberId: str(raw?.workspaceMemberId),
17
+ sessionId: str(raw?.sessionId),
18
+ agent: str(raw?.agent),
19
+ worktreeBranch: str(raw?.worktreeBranch),
20
+ model: str(raw?.model),
21
+ };
22
+ }
23
+ function normalizeSeverity(s) {
24
+ return s === 'HIGH' || s === 'MEDIUM' || s === 'LOW' ? s : 'LOW';
25
+ }
26
+ /**
27
+ * Fetch a task's OPEN (undispositioned) boundary crossings, highest-severity
28
+ * first, via the EXISTING LUM-435 read endpoint — `GET …/boundary-crossings`
29
+ * returns every crossing (open and dispositioned); we keep only the
30
+ * undispositioned ones (`disposition == null`). This is the **read/awareness**
31
+ * half of the acceptance loop: there is no new query and, by construction, no
32
+ * way to clear a crossing — disposition stays web + human-only
33
+ * (LUM-426/435/422).
34
+ *
35
+ * Best-effort: any transport / HTTP / parse failure yields an empty list, so a
36
+ * supplementary safety signal can never break the caller's primary output
37
+ * (the acceptance status, the wrap-up panel).
38
+ */
39
+ async function fetchOpenCrossings(apiUrl, token, taskIdentifier) {
40
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/tasks/${encodeURIComponent(taskIdentifier)}/boundary-crossings`;
41
+ let res;
42
+ try {
43
+ res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ if (!res.ok)
49
+ return [];
50
+ let data;
51
+ try {
52
+ data = (await res.json());
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ const rows = Array.isArray(data.crossings) ? data.crossings : [];
58
+ return rows
59
+ .filter(c => c.disposition == null)
60
+ .map(c => ({
61
+ id: c.id,
62
+ category: c.category,
63
+ severity: normalizeSeverity(c.severity),
64
+ detail: c.detail,
65
+ attribution: normalizeAttribution(c.attribution),
66
+ }))
67
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
68
+ }
69
+ /**
70
+ * The web deep link where a HUMAN dispositions crossings. Disposition is
71
+ * web-only and human-only (LUM-426/435/422); the terminal only ever points
72
+ * here, it never clears anything itself. Built from the workspace slug +
73
+ * identifier alone (the `/my-tasks/<id>` route needs no project slug), so no
74
+ * extra fetch is required.
75
+ */
76
+ function dispositionUrl(apiUrl, workspaceSlug, taskIdentifier) {
77
+ return `${(0, api_1.trimTrailingSlash)(apiUrl)}/workspace/${workspaceSlug}/my-tasks/${taskIdentifier}#boundary-crossings`;
78
+ }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // ── Agent Error types ────────────────────────────────────────────────────────
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
4
+ exports.capRenderedOutput = exports.truncateUnitsToBudget = exports.OUTPUT_TOKEN_BUDGET = exports.estimateTokens = exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
5
5
  exports.userFriendlyError = userFriendlyError;
6
6
  class AgentError extends Error {
7
7
  code;
@@ -46,3 +46,9 @@ Object.defineProperty(exports, "EVIDENCE_POINTER_FORMAT_HINT", { enumerable: tru
46
46
  Object.defineProperty(exports, "EVIDENCE_POINTER_MAX", { enumerable: true, get: function () { return acceptance_evidence_1.EVIDENCE_POINTER_MAX; } });
47
47
  Object.defineProperty(exports, "isValidEvidencePointer", { enumerable: true, get: function () { return acceptance_evidence_1.isValidEvidencePointer; } });
48
48
  Object.defineProperty(exports, "buildCmdEvidencePointer", { enumerable: true, get: function () { return acceptance_evidence_1.buildCmdEvidencePointer; } });
49
+ // ── Agent-facing CLI output-token budget (LUM-428) ───────────────────────────
50
+ var output_budget_1 = require("./output-budget");
51
+ Object.defineProperty(exports, "estimateTokens", { enumerable: true, get: function () { return output_budget_1.estimateTokens; } });
52
+ Object.defineProperty(exports, "OUTPUT_TOKEN_BUDGET", { enumerable: true, get: function () { return output_budget_1.OUTPUT_TOKEN_BUDGET; } });
53
+ Object.defineProperty(exports, "truncateUnitsToBudget", { enumerable: true, get: function () { return output_budget_1.truncateUnitsToBudget; } });
54
+ Object.defineProperty(exports, "capRenderedOutput", { enumerable: true, get: function () { return output_budget_1.capRenderedOutput; } });
@@ -93,10 +93,29 @@ function parseSectionQuery(raw) {
93
93
  return { text: (m[2] ?? '').trim(), depth: (m[1] ?? '').length };
94
94
  return { text: raw.trim() };
95
95
  }
96
+ /**
97
+ * Normalize a heading for the widest match tier (LUM-447): fold full-width
98
+ * ASCII forms (U+FF01–U+FF5E — the (),etc. that pepper CJK headings) down
99
+ * to their half-width counterparts, turn the full-width ideographic space
100
+ * (U+3000) into a normal space, collapse runs of whitespace to one, then
101
+ * lower-case. This lets a half-width `--section` query land on a full-width
102
+ * stored heading (and the reverse) so agents no longer have to grep and copy
103
+ * the raw bytes. It does NOT relax the ambiguity guard: matches are still
104
+ * counted, and more than one is reported as candidates.
105
+ */
106
+ function normalizeHeading(text) {
107
+ return text
108
+ .replace(/[!-~]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
109
+ .replace(/ /g, ' ')
110
+ .replace(/\s+/g, ' ')
111
+ .trim()
112
+ .toLowerCase();
113
+ }
96
114
  /**
97
115
  * Locate a section by heading text. Exact match first, then a
98
- * case-insensitive pass; more than one hit in the winning pass is reported
99
- * as ambiguous rather than silently picking one.
116
+ * case-insensitive pass, then a full-width/half-width + whitespace
117
+ * normalization pass (LUM-447); more than one hit in the winning pass is
118
+ * reported as ambiguous rather than silently picking one.
100
119
  */
101
120
  function findSection(src, query) {
102
121
  const q = parseSectionQuery(query);
@@ -107,6 +126,10 @@ function findSection(src, query) {
107
126
  const lower = q.text.toLowerCase();
108
127
  matches = pool.filter(s => s.heading.toLowerCase() === lower);
109
128
  }
129
+ if (matches.length === 0) {
130
+ const normalized = normalizeHeading(q.text);
131
+ matches = pool.filter(s => normalizeHeading(s.heading) === normalized);
132
+ }
110
133
  const first = matches[0];
111
134
  if (matches.length === 1 && first)
112
135
  return { kind: 'found', section: first };
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * LUM-428 — output-token budget for agent-facing CLI stdout.
4
+ *
5
+ * Anthropic's "Writing effective tools for AI agents" + "Effective context
6
+ * engineering": a tool's response is a contract that spends from the agent's
7
+ * context budget, and Claude Code defaults to truncating a tool result to
8
+ * 25,000 tokens. Lumo's agent-facing commands (`task comments list`,
9
+ * `task context`, `doc show`, …) print straight into that budget, so a single
10
+ * fat comment thread / huge doc must not be allowed to blow it.
11
+ *
12
+ * This module is the CLI-side primitive: a token estimate, the budget anchor,
13
+ * and two cappers that truncate to the budget and append a *lightweight
14
+ * fetch-more pointer* — a just-in-time identifier telling the agent how to pull
15
+ * the rest on demand instead of receiving it all up front.
16
+ *
17
+ * It lives in `shared/` (not the app's `lib/context/budget.ts`) because the CLI
18
+ * imports `shared/` and never the app `lib/`. It is deliberately decoupled from
19
+ * the LUM-402 *injection-side* budget (session-start additionalContext): that
20
+ * one allocates the context window; this one caps command stdout.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.OUTPUT_TOKEN_BUDGET = void 0;
24
+ exports.estimateTokens = estimateTokens;
25
+ exports.truncateUnitsToBudget = truncateUnitsToBudget;
26
+ exports.capRenderedOutput = capRenderedOutput;
27
+ /**
28
+ * Rough token estimate: char-count / 4, rounded up. Stable proxy that avoids a
29
+ * tokenizer dependency — the same heuristic the canonical injection-side budget
30
+ * (`lib/context/budget.ts`) uses, duplicated here only because `shared/` must
31
+ * stay free of any app-`lib/` import.
32
+ */
33
+ function estimateTokens(text) {
34
+ return Math.ceil(text.length / 4);
35
+ }
36
+ /**
37
+ * Default per-command output ceiling for agent-facing CLI stdout, in tokens.
38
+ * Anchored to Anthropic's published Claude Code default (tool results truncated
39
+ * to 25,000 tokens). Generous on purpose: normal outputs sit far below it, so
40
+ * the cap only ever engages on a pathological input (a thousand-comment thread,
41
+ * a book-length doc) — exactly the case that would otherwise silently swallow
42
+ * the agent's whole budget.
43
+ */
44
+ exports.OUTPUT_TOKEN_BUDGET = 25_000;
45
+ /**
46
+ * Chars reserved for the appended pointer line so the final string (body +
47
+ * pointer) still fits the budget. Comfortably larger than any pointer this
48
+ * module builds.
49
+ */
50
+ const POINTER_RESERVE_CHARS = 320;
51
+ function buildPointer(omitted, unitNoun, maxTokens, fetchHint) {
52
+ return (`… +${omitted.toLocaleString('en-US')} more ${unitNoun} not shown ` +
53
+ `(output capped at ${maxTokens.toLocaleString('en-US')} tokens) — ${fetchHint}`);
54
+ }
55
+ /**
56
+ * Truncate an explicit list of repeatable units (e.g. one comment each) to the
57
+ * budget, appending the fetch-more pointer when anything is dropped. Always
58
+ * keeps at least one unit so the output is never just a pointer. The returned
59
+ * `text` is guaranteed to estimate at or under `maxTokens`.
60
+ */
61
+ function truncateUnitsToBudget(params) {
62
+ const maxTokens = params.maxTokens ?? exports.OUTPUT_TOKEN_BUDGET;
63
+ const separator = params.separator ?? '\n\n';
64
+ const { units, unitNoun, fetchHint } = params;
65
+ const full = units.join(separator);
66
+ if (units.length === 0 || estimateTokens(full) <= maxTokens) {
67
+ return {
68
+ text: full,
69
+ truncated: false,
70
+ shownUnits: units.length,
71
+ omittedUnits: 0,
72
+ };
73
+ }
74
+ const charBudget = Math.max(0, maxTokens * 4 - POINTER_RESERVE_CHARS);
75
+ const kept = [];
76
+ let used = 0;
77
+ for (const unit of units) {
78
+ const add = (kept.length === 0 ? 0 : separator.length) + unit.length;
79
+ if (kept.length > 0 && used + add > charBudget)
80
+ break;
81
+ kept.push(unit);
82
+ used += add;
83
+ if (used >= charBudget)
84
+ break;
85
+ }
86
+ let body = kept.join(separator);
87
+ // A single oversized leading unit can still bust the budget — hard-slice it.
88
+ if (body.length > charBudget)
89
+ body = body.slice(0, charBudget);
90
+ const omitted = units.length - kept.length;
91
+ const pointer = buildPointer(omitted, unitNoun, maxTokens, fetchHint);
92
+ return {
93
+ text: `${body}${separator}${pointer}`,
94
+ truncated: true,
95
+ shownUnits: kept.length,
96
+ omittedUnits: omitted,
97
+ };
98
+ }
99
+ /**
100
+ * Cap an already-rendered multi-line string to the budget at a line boundary,
101
+ * appending the fetch-more pointer when truncated. For commands that assemble a
102
+ * single body (a doc render, the task-context handoff) rather than an explicit
103
+ * unit list. The returned `text` estimates at or under `maxTokens`.
104
+ */
105
+ function capRenderedOutput(text, opts) {
106
+ const maxTokens = opts.maxTokens ?? exports.OUTPUT_TOKEN_BUDGET;
107
+ const unitNoun = opts.unitNoun ?? 'lines';
108
+ if (estimateTokens(text) <= maxTokens) {
109
+ return { text, truncated: false, shownUnits: 0, omittedUnits: 0 };
110
+ }
111
+ const charBudget = Math.max(0, maxTokens * 4 - POINTER_RESERVE_CHARS);
112
+ // Fill the budget with a hard char-slice (so a single very long line is
113
+ // sliced, not dropped wholesale), then back up to a line boundary when one
114
+ // sits in the kept tail — keeps the output from ending mid-line.
115
+ let body = text.slice(0, charBudget);
116
+ const lastNewline = body.lastIndexOf('\n');
117
+ if (lastNewline > charBudget / 2)
118
+ body = body.slice(0, lastNewline);
119
+ const totalLines = text.split('\n').length;
120
+ const keptLines = body.length === 0 ? 0 : body.split('\n').length;
121
+ const omitted = Math.max(0, totalLines - keptLines);
122
+ const pointer = buildPointer(omitted, unitNoun, maxTokens, opts.fetchHint);
123
+ return {
124
+ text: `${body}\n\n${pointer}`,
125
+ truncated: true,
126
+ shownUnits: keptLines,
127
+ omittedUnits: omitted,
128
+ };
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",
@@ -1,60 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sessionDetach = sessionDetach;
4
- const config_1 = require("../lib/config");
5
- const api_1 = require("../lib/api");
6
- const sanitize_1 = require("../lib/sanitize");
7
- /**
8
- * `lumo session detach` — clear the task binding on the current Claude Code
9
- * session. Idempotent: re-detaching an already-unbound session reports
10
- * "already unbound" instead of erroring.
11
- *
12
- * Past HookEvent rows keep their original taskId — only future events on
13
- * this session will be unbound. Re-attach later with `session attach
14
- * <LUM-N>` to point the session at a different task.
15
- */
16
- async function sessionDetach() {
17
- const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
18
- if (!sessionId) {
19
- console.error('Error: $CLAUDE_CODE_SESSION_ID is not set.\n' +
20
- '`lumo session detach` must be run inside a Claude Code session.');
21
- return 1;
22
- }
23
- const creds = (0, config_1.readCredentials)();
24
- if (!creds) {
25
- console.error('Error: not logged in. Run `lumo auth login` first.');
26
- return 1;
27
- }
28
- const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
29
- const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
30
- let res;
31
- try {
32
- res = await fetch(url, {
33
- method: 'DELETE',
34
- headers: { Authorization: `Bearer ${creds.token}` },
35
- });
36
- }
37
- catch (err) {
38
- const msg = err instanceof Error ? err.message : String(err);
39
- console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
40
- return 1;
41
- }
42
- if (res.status === 401) {
43
- console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
44
- return 1;
45
- }
46
- if (res.status === 404) {
47
- process.stdout.write(`Session ${sessionId} has no server-side state yet — nothing to detach.\n`);
48
- return;
49
- }
50
- if (!res.ok) {
51
- console.error(`Error: session detach failed (HTTP ${res.status})`);
52
- return 1;
53
- }
54
- const data = (await res.json());
55
- if (data.alreadyUnbound) {
56
- process.stdout.write(`Session ${sessionId} was already unbound.\n`);
57
- return;
58
- }
59
- process.stdout.write(`Detached session ${sessionId} from ${data.previousTaskIdentifier} "${(0, sanitize_1.sanitizeField)(data.previousTaskTitle ?? '')}".\n`);
60
- }