@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.
- package/assets/skill/SKILL.md +9 -7
- package/assets/skill/references/criteria.md +102 -11
- package/assets/skill/references/docs.md +29 -4
- package/assets/skill/references/sessions.md +20 -25
- package/assets/skill/references/task-context.md +21 -6
- package/assets/skill/references/verify.md +25 -0
- package/dist/cli/src/commands/doc-rebuild-source.js +86 -0
- package/dist/cli/src/commands/doc-show.js +114 -77
- package/dist/cli/src/commands/session-attach.js +55 -73
- package/dist/cli/src/commands/session-wrap.js +8 -0
- package/dist/cli/src/commands/task-comment-list.js +37 -9
- package/dist/cli/src/commands/task-context.js +12 -1
- package/dist/cli/src/commands/task-criteria-list.js +6 -0
- package/dist/cli/src/commands/task-criteria-set.js +29 -1
- package/dist/cli/src/commands/task-status.js +82 -5
- package/dist/cli/src/commands/wrap/crossings-reminder.js +41 -0
- package/dist/cli/src/index.js +13 -10
- package/dist/cli/src/lib/open-crossings.js +78 -0
- package/dist/shared/src/index.js +7 -1
- package/dist/shared/src/markdown-sections.js +25 -2
- package/dist/shared/src/output-budget.js +129 -0
- package/package.json +1 -1
- package/dist/cli/src/commands/session-detach.js +0 -60
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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.
|
|
243
|
-
.
|
|
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
|
|
348
|
-
.
|
|
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
|
+
}
|
package/dist/shared/src/index.js
CHANGED
|
@@ -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
|
|
99
|
-
*
|
|
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,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
|
-
}
|