@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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatShowOutput = formatShowOutput;
4
+ exports.selectDocShowOutput = selectDocShowOutput;
4
5
  exports.docShow = docShow;
5
6
  const config_1 = require("../lib/config");
6
7
  const api_1 = require("../lib/api");
@@ -8,6 +9,7 @@ const markdown_tiptap_1 = require("../lib/markdown-tiptap");
8
9
  const markdown_sections_1 = require("../lib/markdown-sections");
9
10
  const resolve_doc_id_1 = require("../lib/resolve-doc-id");
10
11
  const sanitize_1 = require("../lib/sanitize");
12
+ const output_budget_1 = require("../../../shared/src/output-budget");
11
13
  function scopeLabel(s) {
12
14
  if (s === 'PRIVATE')
13
15
  return 'personal';
@@ -27,43 +29,21 @@ function formatShowOutput(vm) {
27
29
  `Mentioned tasks: ${vm.mentionedTasks.length ? vm.mentionedTasks.join(', ') : '-'}`,
28
30
  ];
29
31
  const header = lines.join('\n');
30
- return vm.bodyMarkdown ? `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}` : header;
32
+ if (!vm.bodyMarkdown)
33
+ return header;
34
+ const full = `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}`;
35
+ // The *rendered* view is the only doc-show path with a budget (LUM-428): a
36
+ // book-length doc would otherwise swallow the agent's context. The pointer
37
+ // routes to the byte-faithful reads — `--section` for a just-in-time slice,
38
+ // `--raw` for the whole source — which are NEVER capped (they are edit bases).
39
+ return (0, output_budget_1.capRenderedOutput)(full, {
40
+ fetchHint: `read one section with: lumo doc show ${vm.id} --section "<heading>", ` +
41
+ `or the full markdown source with: lumo doc show ${vm.id} --raw`,
42
+ unitNoun: 'lines',
43
+ }).text;
31
44
  }
32
- async function docShow(reference, opts = {}) {
33
- if (!reference) {
34
- console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw | --section <heading>]');
35
- return 1;
36
- }
37
- if (opts.raw && opts.section !== undefined) {
38
- console.error('Error: --raw and --section are mutually exclusive (--raw prints the whole source)');
39
- return 1;
40
- }
41
- const creds = (0, config_1.readCredentials)();
42
- if (!creds) {
43
- console.error('Error: not logged in. Run `lumo auth login` first.');
44
- return 1;
45
- }
46
- const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
47
- const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
48
- if (!id) {
49
- console.error(`Error: Document not found: ${reference}`);
50
- return 1;
51
- }
52
- const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
53
- headers: { Authorization: `Bearer ${creds.token}` },
54
- });
55
- if (!res.ok) {
56
- const text = await res.text();
57
- console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
58
- return 1;
59
- }
60
- // The exact shape of the GET response is not guaranteed; defensively unwrap.
61
- const data = (await res.json());
62
- const d = data.document;
63
- if (!d) {
64
- console.error('Error: server returned an empty document response');
65
- return 1;
66
- }
45
+ function selectDocShowOutput(d, opts) {
46
+ const revision = typeof d.contentRevision === 'number' ? d.contentRevision : null;
67
47
  if (opts.raw) {
68
48
  // Byte-identical markdown source of the last markdown upload (LUM-408).
69
49
  // Written verbatim (no header, no sanitization, no added newline) so the
@@ -71,30 +51,28 @@ async function docShow(reference, opts = {}) {
71
51
  // No silent fallback to the lossy HTML→markdown reverse render — that
72
52
  // fallback is exactly what flattened tables in LUM-349.
73
53
  if (typeof d.sourceMarkdown !== 'string') {
74
- console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
75
- `or predates markdown source storage). --raw refuses to fall back to the ` +
76
- `lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
77
- `reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
78
- `— from then on --raw works.`);
79
- return 1;
54
+ return {
55
+ kind: 'error',
56
+ message: `Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
57
+ `or predates markdown source storage). --raw refuses to fall back to the ` +
58
+ `lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
59
+ `reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
60
+ `— from then on --raw works.`,
61
+ };
80
62
  }
81
- process.stdout.write(d.sourceMarkdown);
82
- // Revision goes to stderr so stdout stays a byte-pure edit base while
83
- // the agent still sees the number to pass back as --if-revision.
84
- if (typeof d.contentRevision === 'number') {
85
- console.error(`Revision: ${d.contentRevision}`);
86
- }
87
- return;
63
+ return { kind: 'bytes', text: d.sourceMarkdown, revision };
88
64
  }
89
65
  if (opts.section !== undefined) {
90
66
  // Section reads slice the stored markdown source — same no-fallback rule
91
67
  // as --raw: a rendered-output slice would not be a legal edit base.
92
68
  if (typeof d.sourceMarkdown !== 'string') {
93
- console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
94
- `or predates markdown source storage), so --section cannot slice it. ` +
95
- `Rebuild a base first: run \`lumo doc show ${d.id}\`, reconstruct the markdown ` +
96
- `faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\`.`);
97
- return 1;
69
+ return {
70
+ kind: 'error',
71
+ message: `Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
72
+ `or predates markdown source storage), so --section cannot slice it. ` +
73
+ `Rebuild a base first: run \`lumo doc show ${d.id}\`, reconstruct the markdown ` +
74
+ `faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\`.`,
75
+ };
98
76
  }
99
77
  const match = (0, markdown_sections_1.findSection)(d.sourceMarkdown, opts.section);
100
78
  if (match.kind === 'not-found') {
@@ -102,24 +80,27 @@ async function docShow(reference, opts = {}) {
102
80
  .slice(0, 30)
103
81
  .map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)}`)
104
82
  .join('\n');
105
- console.error(`Error: section not found: "${opts.section}". Available headings:\n${list || ' (none)'}`);
106
- return 1;
83
+ return {
84
+ kind: 'error',
85
+ message: `Error: section not found: "${opts.section}". Available headings:\n${list || ' (none)'}`,
86
+ };
107
87
  }
108
88
  if (match.kind === 'ambiguous') {
109
89
  const list = match.candidates
110
90
  .map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)} (line ${s.line + 1})`)
111
91
  .join('\n');
112
- console.error(`Error: section "${opts.section}" is ambiguous — ${match.candidates.length} headings match:\n${list}\n` +
113
- `Disambiguate by depth, e.g. --section "${'#'.repeat(match.candidates[0].depth)} ${match.candidates[0].heading}"`);
114
- return 1;
115
- }
116
- // Byte-faithful slice, verbatim on stdout (legal patch base); revision on
117
- // stderr for the follow-up `doc patch --if-revision`.
118
- process.stdout.write((0, markdown_sections_1.extractSection)(d.sourceMarkdown, match.section));
119
- if (typeof d.contentRevision === 'number') {
120
- console.error(`Revision: ${d.contentRevision}`);
92
+ return {
93
+ kind: 'error',
94
+ message: `Error: section "${opts.section}" is ambiguous — ${match.candidates.length} headings match:\n${list}\n` +
95
+ `Disambiguate by depth, e.g. --section "${'#'.repeat(match.candidates[0].depth)} ${match.candidates[0].heading}"`,
96
+ };
121
97
  }
122
- return;
98
+ // Byte-faithful slice, verbatim (legal patch base).
99
+ return {
100
+ kind: 'bytes',
101
+ text: (0, markdown_sections_1.extractSection)(d.sourceMarkdown, match.section),
102
+ revision,
103
+ };
123
104
  }
124
105
  // Server returns `contentMarkdown` derived from the HTML body (LUM-83+).
125
106
  // Fall back to parsing the raw content as legacy Tiptap JSON for docs
@@ -149,15 +130,71 @@ async function docShow(reference, opts = {}) {
149
130
  return null;
150
131
  })
151
132
  .filter((x) => x !== null);
152
- console.log(formatShowOutput({
153
- id: d.id,
154
- title: d.title,
155
- scope: d.visibility ?? 'PRIVATE',
156
- projectName: d.project?.name ?? null,
157
- createdAt: d.createdAt ?? '',
158
- updatedAt: d.updatedAt ?? '',
159
- revision: typeof d.contentRevision === 'number' ? d.contentRevision : null,
160
- mentionedTasks,
161
- bodyMarkdown: body,
162
- }));
133
+ return {
134
+ kind: 'rendered',
135
+ text: formatShowOutput({
136
+ id: d.id,
137
+ title: d.title,
138
+ scope: d.visibility ?? 'PRIVATE',
139
+ projectName: d.project?.name ?? null,
140
+ createdAt: d.createdAt ?? '',
141
+ updatedAt: d.updatedAt ?? '',
142
+ revision,
143
+ mentionedTasks,
144
+ bodyMarkdown: body,
145
+ }),
146
+ };
147
+ }
148
+ async function docShow(reference, opts = {}) {
149
+ if (!reference) {
150
+ console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw | --section <heading>]');
151
+ return 1;
152
+ }
153
+ if (opts.raw && opts.section !== undefined) {
154
+ console.error('Error: --raw and --section are mutually exclusive (--raw prints the whole source)');
155
+ return 1;
156
+ }
157
+ const creds = (0, config_1.readCredentials)();
158
+ if (!creds) {
159
+ console.error('Error: not logged in. Run `lumo auth login` first.');
160
+ return 1;
161
+ }
162
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
163
+ const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
164
+ if (!id) {
165
+ console.error(`Error: Document not found: ${reference}`);
166
+ return 1;
167
+ }
168
+ const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
169
+ headers: { Authorization: `Bearer ${creds.token}` },
170
+ });
171
+ if (!res.ok) {
172
+ const text = await res.text();
173
+ console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
174
+ return 1;
175
+ }
176
+ // The exact shape of the GET response is not guaranteed; defensively unwrap.
177
+ const data = (await res.json());
178
+ const d = data.document;
179
+ if (!d) {
180
+ console.error('Error: server returned an empty document response');
181
+ return 1;
182
+ }
183
+ const sel = selectDocShowOutput(d, opts);
184
+ if (sel.kind === 'error') {
185
+ console.error(sel.message);
186
+ return 1;
187
+ }
188
+ if (sel.kind === 'bytes') {
189
+ // Byte-faithful read (--raw / --section): written verbatim, NEVER routed
190
+ // through the output cap — it is a legal edit base for `doc patch`
191
+ // (LUM-408/409/428). Revision goes to stderr so stdout stays byte-pure
192
+ // while the agent still sees the number to pass back as --if-revision.
193
+ process.stdout.write(sel.text);
194
+ if (sel.revision !== null) {
195
+ console.error(`Revision: ${sel.revision}`);
196
+ }
197
+ return;
198
+ }
199
+ console.log(sel.text);
163
200
  }
@@ -4,26 +4,20 @@ exports.sessionAttach = sessionAttach;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
- const line_prompt_1 = require("../lib/line-prompt");
8
7
  /**
9
8
  * `lumo session attach <identifier>` — bind the currently-running
10
9
  * Claude Code session to a task.
11
10
  *
12
11
  * Required environment: `CLAUDE_CODE_SESSION_ID` (set automatically by
13
- * Claude Code). Must be invoked from inside a Claude Code session — there
14
- * is no longer a global "current task" pointer for terminals outside CC.
12
+ * Claude Code). Must be invoked from inside a Claude Code session.
15
13
  *
16
- * The binding lives entirely on the server (`Session.taskId`); subsequent
17
- * hooks read it back via the session row. The CLI keeps no local sentinel.
18
- *
19
- * Re-binding a session that's already attached to a *different* task no
20
- * longer silently clobbers `Session.taskId` (LUM-266): the server returns
21
- * the current binding and we confirm before overwriting —
22
- * - `--force` skips the prompt and overwrites directly;
23
- * - on a TTY we ask `Already bound to LUM-X. Rebind to LUM-Y? [y/N]`;
24
- * - off a TTY (the usual agent case) we refuse and point at `--force`.
14
+ * The binding is a **lifetime lock** (LUM-459): `Session.taskId` is write-once.
15
+ * Re-attaching to the *same* task is an idempotent no-op (re-emits context).
16
+ * Attaching to a *different* task is refused with HTTP 409 — there is no
17
+ * `--force` and no `session detach`; a different task requires a brand-new
18
+ * Claude Code session.
25
19
  */
26
- async function sessionAttach(identifier, options = {}) {
20
+ async function sessionAttach(identifier) {
27
21
  if (!identifier) {
28
22
  console.error('Error: missing <identifier>. Usage: lumo session attach <LUM-42>');
29
23
  return 1;
@@ -41,75 +35,63 @@ async function sessionAttach(identifier, options = {}) {
41
35
  }
42
36
  const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
43
37
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
44
- const bind = async (force) => {
45
- let res;
38
+ let res;
39
+ try {
40
+ res = await fetch(url, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ Authorization: `Bearer ${creds.token}`,
45
+ },
46
+ body: JSON.stringify({ taskIdentifier: identifier }),
47
+ });
48
+ }
49
+ catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
52
+ return 1;
53
+ }
54
+ if (res.status === 401) {
55
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
56
+ return 1;
57
+ }
58
+ if (res.status === 404) {
59
+ let message = 'Not found';
46
60
  try {
47
- res = await fetch(url, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json',
51
- Authorization: `Bearer ${creds.token}`,
52
- },
53
- body: JSON.stringify({ taskIdentifier: identifier, force }),
54
- });
55
- }
56
- catch (err) {
57
- const msg = err instanceof Error ? err.message : String(err);
58
- console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
59
- return { ok: false, code: 1 };
61
+ const data = (await res.json());
62
+ if (data.error)
63
+ message = data.error;
60
64
  }
61
- if (res.status === 401) {
62
- console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
63
- return { ok: false, code: 1 };
65
+ catch {
66
+ // fall through
64
67
  }
65
- if (res.status === 404) {
66
- let message = 'Not found';
67
- try {
68
- const data = (await res.json());
69
- if (data.error)
70
- message = data.error;
71
- }
72
- catch {
73
- // fall through
68
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
69
+ return 1;
70
+ }
71
+ // Lifetime lock (LUM-459): the session is permanently bound to another task.
72
+ if (res.status === 409) {
73
+ let current = 'another task';
74
+ try {
75
+ const data = (await res.json());
76
+ if (data.currentTaskIdentifier) {
77
+ current = data.currentTaskTitle
78
+ ? `${data.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(data.currentTaskTitle)}"`
79
+ : data.currentTaskIdentifier;
74
80
  }
75
- console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
76
- return { ok: false, code: 1 };
77
81
  }
78
- if (!res.ok) {
79
- console.error(`Error: bind-task failed (HTTP ${res.status})`);
80
- return { ok: false, code: 1 };
82
+ catch {
83
+ // fall through with the generic phrasing
81
84
  }
82
- return { ok: true, body: (await res.json()) };
83
- };
84
- // First attempt. `--force` overwrites unconditionally; otherwise the server
85
- // may answer `already-bound` so we can confirm before clobbering.
86
- const first = await bind(options.force === true);
87
- if (!first.ok)
88
- return first.code;
89
- let body = first.body;
90
- if (body.status === 'already-bound') {
91
- // Reached only when not forced (force=true would have overwritten).
92
- if (!process.stdin.isTTY) {
93
- console.error(`Session already bound to ${body.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(body.currentTaskTitle)}". ` +
94
- `Not overwriting. Re-run with --force to switch to ${identifier} ` +
95
- '(or run `lumo session detach` first).');
96
- return 0;
97
- }
98
- const answer = await (0, line_prompt_1.promptLine)(`Already bound to ${body.currentTaskIdentifier}. Rebind to ${identifier}? [y/N] `);
99
- if (!/^y(es)?$/i.test(answer)) {
100
- console.log(`Cancelled — still bound to ${body.currentTaskIdentifier}.`);
101
- return 0;
102
- }
103
- const second = await bind(true);
104
- if (!second.ok)
105
- return second.code;
106
- body = second.body;
85
+ console.error(`Error: this session is permanently bound to ${current}. ` +
86
+ 'A session works one task for its lifetime — start a new Claude Code ' +
87
+ `session to work on ${identifier}.`);
88
+ return 1;
107
89
  }
108
- if (body.status === 'already-bound') {
109
- // Defensive: a forced bind should never report already-bound.
110
- console.error(`Error: bind did not take effect (still bound to ${body.currentTaskIdentifier}).`);
90
+ if (!res.ok) {
91
+ console.error(`Error: bind-task failed (HTTP ${res.status})`);
111
92
  return 1;
112
93
  }
94
+ const body = (await res.json());
113
95
  console.log(`Attached session ${sessionId} to ${body.taskIdentifier} "${(0, sanitize_1.sanitizeField)(body.taskTitle)}"`);
114
96
  console.log(`Re-tagged ${body.retaggedEventCount} previously-untagged event${body.retaggedEventCount === 1 ? '' : 's'} in this session.`);
115
97
  // Warnings come before contract/memory: matches the hook injection order —
@@ -7,6 +7,7 @@ const progress_comment_section_1 = require("./wrap/progress-comment-section");
7
7
  const memory_review_section_1 = require("./wrap/memory-review-section");
8
8
  const fragment_usage_section_1 = require("./wrap/fragment-usage-section");
9
9
  const blocked_prompt_section_1 = require("./wrap/blocked-prompt-section");
10
+ const crossings_reminder_1 = require("./wrap/crossings-reminder");
10
11
  /**
11
12
  * `lumo session wrap [--yes] [--dry-run]`
12
13
  *
@@ -40,4 +41,11 @@ async function sessionWrap(options) {
40
41
  yes: options.yes === true,
41
42
  dryRun: options.dryRun === true,
42
43
  });
44
+ // After the panel: a read-only nudge if the bound task has open boundary
45
+ // crossings still undispositioned (LUM-448). Silent when there are none —
46
+ // a clean task adds no wrap-up noise. Awareness only; clearing a crossing is
47
+ // web + human-only (LUM-426/435/422).
48
+ const reminder = await (0, crossings_reminder_1.openCrossingReminder)(creds);
49
+ if (reminder)
50
+ process.stdout.write(reminder);
43
51
  }
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCommentThread = formatCommentThread;
3
4
  exports.taskCommentList = taskCommentList;
4
5
  const config_1 = require("../lib/config");
5
6
  const api_1 = require("../lib/api");
6
7
  const sanitize_1 = require("../lib/sanitize");
8
+ const output_budget_1 = require("../../../shared/src/output-budget");
7
9
  /**
8
10
  * Strip TipTap/HTML markup to readable plain text. Comment bodies are stored as
9
11
  * HTML; the CLI only needs the visible text. We collapse block tags to newlines
@@ -23,17 +25,42 @@ function htmlToPlainText(html) {
23
25
  .replace(/\n{3,}/g, '\n\n')
24
26
  .trim();
25
27
  }
26
- function printComment(c, indent) {
28
+ /** Render one comment (and its nested replies) as an indented text block. */
29
+ function renderCommentBlock(c, indent) {
30
+ const lines = [];
27
31
  const author = (0, sanitize_1.sanitizeField)(c.authorActorId ?? 'unknown');
28
- console.log(`${indent}${author} · ${c.createdAt}`);
32
+ lines.push(`${indent}${author} · ${c.createdAt}`);
29
33
  const text = (0, sanitize_1.sanitizeField)(htmlToPlainText(c.body));
30
34
  for (const line of (text || '(empty)').split('\n')) {
31
- console.log(`${indent}${line}`);
35
+ lines.push(`${indent}${line}`);
32
36
  }
33
- console.log('');
34
37
  for (const reply of c.replies ?? []) {
35
- printComment(reply, indent + ' ');
38
+ lines.push('');
39
+ lines.push(renderCommentBlock(reply, indent + ' '));
36
40
  }
41
+ return lines.join('\n');
42
+ }
43
+ /**
44
+ * Render the full comment thread as agent-facing text. One block per top-level
45
+ * comment (replies indented underneath). Capped to the output-token budget by
46
+ * default (LUM-428) — a long-running task's thread is one of the easiest ways
47
+ * to blow the agent's context — with a fetch-more pointer to `--full`. Pass
48
+ * `{ full: true }` to print everything.
49
+ */
50
+ function formatCommentThread(comments, opts = {}) {
51
+ if (comments.length === 0)
52
+ return '(no comments)';
53
+ const blocks = comments.map(c => renderCommentBlock(c, ''));
54
+ if (opts.full)
55
+ return blocks.join('\n\n');
56
+ const id = opts.identifier ?? '<LUM-N>';
57
+ return (0, output_budget_1.truncateUnitsToBudget)({
58
+ units: blocks,
59
+ maxTokens: opts.maxTokens,
60
+ unitNoun: 'comments',
61
+ fetchHint: `read the whole thread with: lumo task comments list ${id} --full`,
62
+ separator: '\n\n',
63
+ }).text;
37
64
  }
38
65
  /**
39
66
  * `lumo task comments list <LUM-N>`
@@ -43,7 +70,7 @@ function printComment(c, indent) {
43
70
  * thread from `/api/tasks/:id/comments` and prints each top-level comment
44
71
  * (replies indented) as `author · time` followed by the plain-text body.
45
72
  */
46
- async function taskCommentList(identifier) {
73
+ async function taskCommentList(identifier, opts = {}) {
47
74
  if (!identifier) {
48
75
  console.error('Error: usage: lumo task comments list <LUM-42>');
49
76
  return 1;
@@ -105,7 +132,8 @@ async function taskCommentList(identifier) {
105
132
  console.log('(no comments)');
106
133
  return;
107
134
  }
108
- for (const c of comments) {
109
- printComment(c, '');
110
- }
135
+ console.log(formatCommentThread(comments, {
136
+ full: opts.full,
137
+ identifier: resolved.identifier,
138
+ }));
111
139
  }
@@ -6,6 +6,7 @@ const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const sanitize_1 = require("../lib/sanitize");
8
8
  const format_1 = require("../lib/format");
9
+ const output_budget_1 = require("../../../shared/src/output-budget");
9
10
  async function taskContext(identifier) {
10
11
  if (!identifier) {
11
12
  console.error('Error: missing <identifier>. Usage: lumo task context <LUM-42>');
@@ -144,5 +145,15 @@ function formatTaskContextMarkdown(data, now) {
144
145
  lines.push((0, sanitize_1.sanitizeField)(data.lineageSection.trimEnd()));
145
146
  lines.push('');
146
147
  }
147
- return lines.join('\n');
148
+ // A memory-rich task with a long comment thread and many sessions is the
149
+ // single most context-hungry agent-facing command (LUM-428). Cap the
150
+ // assembled handoff to the output budget; the pointer routes the agent to the
151
+ // precise sub-commands to pull any dropped section just-in-time.
152
+ const id = data.task.identifier;
153
+ return (0, output_budget_1.capRenderedOutput)(lines.join('\n'), {
154
+ fetchHint: `pull the dropped sections individually: lumo task status ${id} · ` +
155
+ `lumo task comments list ${id} --full · lumo task lineage ${id} · ` +
156
+ `lumo doc show <doc>`,
157
+ unitNoun: 'lines',
158
+ }).text;
148
159
  }
@@ -20,6 +20,12 @@ function formatCriteriaRows(criteria) {
20
20
  if (c.checkpointer) {
21
21
  lines.push(` ↳ check: ${(0, sanitize_1.sanitizeField)(c.checkpointer)}`);
22
22
  }
23
+ if (c.judgeSteps) {
24
+ const judgeLines = (0, sanitize_1.sanitizeField)(c.judgeSteps).split('\n');
25
+ lines.push(` ↳ judge: ${judgeLines[0]}`);
26
+ for (const jl of judgeLines.slice(1))
27
+ lines.push(` ${jl}`);
28
+ }
23
29
  }
24
30
  return lines.length > 0 ? lines.join('\n') + '\n' : '';
25
31
  }
@@ -104,6 +104,31 @@ async function taskCriteriaSet(identifier, options) {
104
104
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
105
105
  if (sessionId)
106
106
  headers['X-Lumo-Session-Id'] = sessionId;
107
+ // LUM-437: when a contract already exists, preserve criterion identity across
108
+ // an after-start edit by matching submitted items to existing criteria by exact
109
+ // statement and attaching their ids. Best-effort — fall back to id-less submit.
110
+ let criteriaItems = parsed.items;
111
+ if (!options.human) {
112
+ try {
113
+ const listRes = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria`, { headers: { Authorization: `Bearer ${creds.token}` } });
114
+ if (listRes.ok) {
115
+ const listData = (await listRes.json());
116
+ const activeByStatement = new Map(listData.criteria.map(c => [c.statement, c.id]));
117
+ criteriaItems = parsed.items.map(item => {
118
+ if (item !== null && typeof item === 'object' && !('id' in item)) {
119
+ const stmt = item['statement'];
120
+ if (typeof stmt === 'string' && activeByStatement.has(stmt)) {
121
+ return { ...item, id: activeByStatement.get(stmt) };
122
+ }
123
+ }
124
+ return item;
125
+ });
126
+ }
127
+ }
128
+ catch {
129
+ // leave criteriaItems as-is; the server still records via full diff
130
+ }
131
+ }
107
132
  let res;
108
133
  try {
109
134
  res = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria`, {
@@ -111,7 +136,7 @@ async function taskCriteriaSet(identifier, options) {
111
136
  headers,
112
137
  body: JSON.stringify({
113
138
  source: options.human ? 'HUMAN_EDIT' : 'AGENT_DRAFT',
114
- criteria: parsed.items,
139
+ criteria: criteriaItems,
115
140
  ...(causeTag ? { causeTag } : {}),
116
141
  }),
117
142
  });
@@ -143,4 +168,7 @@ async function taskCriteriaSet(identifier, options) {
143
168
  if (data.warning) {
144
169
  process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.warning)}\n`);
145
170
  }
171
+ if (data.judgeStepsWarning) {
172
+ process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.judgeStepsWarning)}\n`);
173
+ }
146
174
  }