@lumoai/cli 1.59.0 → 1.61.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.
@@ -0,0 +1,342 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeCrossingDisposition = describeCrossingDisposition;
4
+ exports.describeRulingConfirmation = describeRulingConfirmation;
5
+ exports.crossingDisposition = crossingDisposition;
6
+ const config_1 = require("../lib/config");
7
+ const api_1 = require("../lib/api");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ const confirmation_1 = require("../lib/confirmation");
10
+ const bound_task_1 = require("../lib/bound-task");
11
+ const open_crossings_1 = require("../lib/open-crossings");
12
+ /**
13
+ * The changes[] block of the read envelope (step 1, exit 4): what ruling is
14
+ * about to be recorded, on which crossing, with everything the user needs to
15
+ * judge it — severity, category, detail, recurrence, and the agent's own
16
+ * explanations (labelled as an unverified self-report). The ⚠ lines carry
17
+ * the consequence: approving clears this crossing's block on DONE.
18
+ */
19
+ function describeCrossingDisposition(crossing, target, ctx) {
20
+ const lines = [
21
+ `Will disposition crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} on ${ctx.taskIdentifier} as ${target}`,
22
+ `Disposition: ${crossing.disposition ?? 'OPEN'} → ${target}`,
23
+ ];
24
+ const detail = (0, sanitize_1.sanitizeField)(crossing.detail).replace(/\s+/g, ' ').trim();
25
+ lines.push(`[${crossing.severity}] ${(0, sanitize_1.sanitizeField)(crossing.category)}${detail ? ` — ${detail}` : ''}`);
26
+ if ((crossing.occurrenceCount ?? 1) > 1) {
27
+ lines.push(`Seen ×${crossing.occurrenceCount} while open`);
28
+ }
29
+ const explanations = crossing.explanations ?? [];
30
+ if (explanations.length === 0) {
31
+ lines.push('Agent explanations: none recorded');
32
+ }
33
+ else {
34
+ lines.push(`Agent explanations (${explanations.length}, agent self-report · unverified):`);
35
+ for (const e of explanations) {
36
+ lines.push(` - ${(0, sanitize_1.sanitizeField)(e.note).replace(/\s+/g, ' ').trim()}`);
37
+ }
38
+ }
39
+ if (ctx.note)
40
+ lines.push(`Note to record: ${(0, sanitize_1.sanitizeField)(ctx.note)}`);
41
+ if (crossing.advisory) {
42
+ lines.push('Advisory crossing — it never blocked DONE; the ruling is for the record only');
43
+ }
44
+ else {
45
+ lines.push(`⚠ Approving clears this crossing's block on moving ${ctx.taskIdentifier} to DONE`);
46
+ }
47
+ if (crossing.severity === 'HIGH') {
48
+ lines.push('⚠ HIGH severity — this class always needs a human decision; make sure the user has read the detail above');
49
+ }
50
+ if (crossing.reversible === false) {
51
+ lines.push('⚠ Irreversible category — the action reached outside the repo; a false-positive ruling should rest on the detail, not on the explanation alone');
52
+ }
53
+ return lines;
54
+ }
55
+ /**
56
+ * The changes[] block of the ruling envelope (step 2, exit 4): the read is
57
+ * acknowledged and recorded; what remains is the ruling itself.
58
+ */
59
+ function describeRulingConfirmation(crossing, target, ctx) {
60
+ const lines = [
61
+ `Read acknowledged for crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} (shown ${ctx.readAt}, acknowledged ${ctx.readAcknowledgedAt}) — recorded with the ruling`,
62
+ `Step 2 of 2 — record the ruling: ${crossing.disposition ?? 'OPEN'} → ${target} on ${ctx.taskIdentifier}`,
63
+ ];
64
+ if (ctx.note)
65
+ lines.push(`Note to record: ${(0, sanitize_1.sanitizeField)(ctx.note)}`);
66
+ lines.push(crossing.advisory
67
+ ? 'Advisory crossing — it never blocked DONE; the ruling is for the record only'
68
+ : `⚠ Approving clears this crossing's block on moving ${ctx.taskIdentifier} to DONE`);
69
+ return lines;
70
+ }
71
+ /**
72
+ * `lumo crossing disposition <id> --false-positive | --confirmed [--note …]
73
+ * [--task LUM-N] [--receipt <token>] [--confirm-read | --confirm]` — rule on
74
+ * a boundary crossing from the terminal (LUM-769), through a THREE-step
75
+ * confirmation handshake the server enforces:
76
+ *
77
+ * 1. no step flag: read the crossing, obtain a stage-1 READ RECEIPT
78
+ * (bound to crossing, ruling, caller and the crossing's current state),
79
+ * print the read envelope (exit 4), write nothing. Its confirmCommand
80
+ * carries `--receipt <r1> --confirm-read`;
81
+ * 2. `--confirm-read --receipt <r1>`: the user confirmed they read it. The
82
+ * server verifies r1 and issues the stage-2 receipt; the CLI prints the
83
+ * ruling envelope (exit 4), write nothing. Its confirmCommand carries
84
+ * `--receipt <r2> --confirm`;
85
+ * 3. `--confirm --receipt <r2>`: the user approved the ruling. The server
86
+ * verifies r2 — a stage-1 receipt is refused with "confirm read first",
87
+ * as is anything forged, expired, foreign or stale — then writes, with
88
+ * the shown / acknowledged / confirmed timestamps on the audit row.
89
+ *
90
+ * Skipping or reordering a step is refused at the CLI before any request
91
+ * where it can be told locally (no receipt, a stage-1 receipt with --confirm,
92
+ * a stage-2 receipt with --confirm-read) and by the server otherwise. The
93
+ * agent relays each envelope and never supplies a step flag on its own.
94
+ * Server-side the ruling is stamped `channel: 'CLI'` so the audit trail keeps
95
+ * it apart from a web-panel click. What stays web-only: reverting to OPEN and
96
+ * the repository suppression rule — neither exists on this path.
97
+ */
98
+ async function crossingDisposition(crossingId, options = {}) {
99
+ if (!crossingId || crossingId.trim() === '') {
100
+ console.error('Error: a crossing id is required: lumo crossing disposition <id> --false-positive | --confirmed');
101
+ return 1;
102
+ }
103
+ const target = pickTarget(options);
104
+ if (!target) {
105
+ console.error('Error: pass exactly one of --false-positive or --confirmed (the ruling to record).');
106
+ return 1;
107
+ }
108
+ if (options.confirmRead && options.confirm) {
109
+ console.error('Error: --confirm-read and --confirm are separate steps — run them one at a time, each from the envelope the previous step printed.');
110
+ return 1;
111
+ }
112
+ const note = options.note?.trim() || undefined;
113
+ const receipt = options.receipt?.trim() || undefined;
114
+ // Local ordering checks — cheap, and they name the missing step before any
115
+ // request goes out. The server re-checks all of them.
116
+ if (options.confirm) {
117
+ if (!receipt) {
118
+ console.error('Error: --confirm requires the stage-2 read receipt (--receipt <token>) from the --confirm-read step. Run the command without any step flag first, relay the envelope, confirm the read, relay that envelope, then re-run its confirmCommand verbatim.');
119
+ return 1;
120
+ }
121
+ if (receipt.startsWith('r1.')) {
122
+ console.error('Error: read not yet acknowledged — this is the stage-1 receipt. Run the command with --confirm-read --receipt <this token> first (after the user confirms they read the envelope); only the receipt that step returns is accepted with --confirm.');
123
+ return 1;
124
+ }
125
+ }
126
+ if (options.confirmRead) {
127
+ if (!receipt) {
128
+ console.error('Error: --confirm-read requires the read receipt (--receipt <token>) from the envelope. Run the command without any step flag first and relay the envelope.');
129
+ return 1;
130
+ }
131
+ if (receipt.startsWith('r2.')) {
132
+ console.error('Error: this receipt already acknowledges the read — the next step is --confirm with this same receipt (after the user approves the ruling).');
133
+ return 1;
134
+ }
135
+ }
136
+ const creds = (0, config_1.readCredentials)();
137
+ if (!creds) {
138
+ console.error('Error: not logged in. Run `lumo auth login` first.');
139
+ return 1;
140
+ }
141
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
142
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
143
+ const headers = {
144
+ Authorization: `Bearer ${creds.token}`,
145
+ };
146
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
147
+ if (sessionId)
148
+ headers['X-Lumo-Session-Id'] = sessionId;
149
+ const bound = await (0, bound_task_1.resolveBoundTask)({
150
+ base,
151
+ headers,
152
+ explicit: options.task,
153
+ sessionId,
154
+ });
155
+ if (!bound.ok) {
156
+ console.error(bound.message);
157
+ return 1;
158
+ }
159
+ const taskId = bound.taskIdentifier;
160
+ // Read the crossing first, on every path: the envelopes need it, and a
161
+ // confirmed write against a crossing that can't be read must not go out
162
+ // (fail closed — a read hiccup never turns into a blind ruling).
163
+ const read = await readCrossing(base, headers, taskId, crossingId);
164
+ if (read.status === 'error') {
165
+ console.error(`Error: could not confirm the crossing (${read.reason}) — nothing was written.`);
166
+ return 1;
167
+ }
168
+ const crossing = read.crossing;
169
+ if (!crossing) {
170
+ console.error(`Error: crossing ${(0, sanitize_1.sanitizeField)(crossingId)} not found on ${taskId}. Ids are listed by \`lumo task status ${taskId}\`.`);
171
+ return 1;
172
+ }
173
+ if (crossing.disposition === target) {
174
+ process.stdout.write(`Crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} is already ${target} — nothing to do.\n`);
175
+ return;
176
+ }
177
+ // Step 1 — read envelope.
178
+ if (!options.confirmRead && !options.confirm) {
179
+ const issued = await requestReceipt(base, headers, taskId, crossingId, {
180
+ disposition: target,
181
+ });
182
+ if (issued.status === 'error') {
183
+ console.error(`Error: could not obtain a read receipt for the crossing (${issued.reason}) — nothing was written and no envelope can be issued.`);
184
+ return 1;
185
+ }
186
+ return (0, confirmation_1.emitConfirmation)({
187
+ command: 'crossing disposition',
188
+ changes: [
189
+ ...describeCrossingDisposition(crossing, target, {
190
+ taskIdentifier: taskId,
191
+ note,
192
+ }),
193
+ `Step 1 of 2 — read receipt issued for this crossing as read above (valid until ${issued.expiresAt}; void if the crossing changes). Next: the user confirms they have READ it, then run the command below; the ruling itself is confirmed in a second envelope`,
194
+ ],
195
+ confirmFlag: '--confirm-read',
196
+ receipt: { token: issued.receipt, expiresAt: issued.expiresAt },
197
+ });
198
+ }
199
+ // Step 2 — acknowledge the read, get the stage-2 receipt, ruling envelope.
200
+ if (options.confirmRead) {
201
+ const issued = await requestReceipt(base, headers, taskId, crossingId, {
202
+ disposition: target,
203
+ acknowledge: receipt,
204
+ });
205
+ if (issued.status === 'error') {
206
+ console.error(`Error: read acknowledgement rejected (${issued.reason}) — nothing was written.`);
207
+ return 1;
208
+ }
209
+ return (0, confirmation_1.emitConfirmation)({
210
+ command: 'crossing disposition',
211
+ changes: describeRulingConfirmation(crossing, target, {
212
+ taskIdentifier: taskId,
213
+ note,
214
+ readAt: issued.readAt,
215
+ readAcknowledgedAt: issued.readAcknowledgedAt ?? issued.readAt,
216
+ }),
217
+ confirmFlag: '--confirm',
218
+ receipt: { token: issued.receipt, expiresAt: issued.expiresAt },
219
+ });
220
+ }
221
+ // Step 3 — the write.
222
+ let res;
223
+ try {
224
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings/${encodeURIComponent(crossingId)}/disposition`, {
225
+ method: 'POST',
226
+ headers: { ...headers, 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({
228
+ disposition: target,
229
+ ...(note ? { dispositionNote: note } : {}),
230
+ receipt,
231
+ }),
232
+ });
233
+ }
234
+ catch (err) {
235
+ const msg = err instanceof Error ? err.message : String(err);
236
+ console.error(`Error: could not reach Lumo API (${msg})`);
237
+ return 1;
238
+ }
239
+ if (res.status === 401) {
240
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
241
+ return 1;
242
+ }
243
+ if (!res.ok) {
244
+ const errBody = (await res.json().catch(() => null));
245
+ const detail = errBody && typeof errBody.error === 'string'
246
+ ? (0, sanitize_1.sanitizeField)(errBody.error)
247
+ : '';
248
+ console.error(`Error: disposition rejected (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
249
+ return 1;
250
+ }
251
+ const outcome = (await res.json());
252
+ const url = (0, open_crossings_1.dispositionUrl)(apiUrl, creds.workspaceSlug ?? '', taskId);
253
+ process.stdout.write(`✓ Dispositioned crossing ${(0, sanitize_1.sanitizeField)(outcome.crossingId)} as ${outcome.disposition ?? 'OPEN'} (read acknowledged and ruling approved via CLI).\n` +
254
+ (crossing.advisory
255
+ ? ''
256
+ : ` This clears its block on moving ${taskId} to DONE. `) +
257
+ `The ruling is audited on the task timeline as a CLI-channel disposition with the shown / read-acknowledged / confirmed times; it can be corrected in the web panel: ${url}\n`);
258
+ return;
259
+ }
260
+ function pickTarget(options) {
261
+ if (options.falsePositive && options.confirmed)
262
+ return null;
263
+ if (options.falsePositive)
264
+ return 'FALSE_POSITIVE';
265
+ if (options.confirmed)
266
+ return 'CONFIRMED';
267
+ return null;
268
+ }
269
+ /** The handshake's server call (LUM-769): stage 1 without `acknowledge`,
270
+ * stage 2 with the stage-1 receipt in it. Fails closed — without a receipt
271
+ * no envelope is issued, because its confirmCommand could never be accepted. */
272
+ async function requestReceipt(base, headers, taskId, crossingId, body) {
273
+ let res;
274
+ try {
275
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings/${encodeURIComponent(crossingId)}/disposition/receipt`, {
276
+ method: 'POST',
277
+ headers: { ...headers, 'Content-Type': 'application/json' },
278
+ body: JSON.stringify(body),
279
+ });
280
+ }
281
+ catch (err) {
282
+ return {
283
+ status: 'error',
284
+ reason: err instanceof Error ? err.message : 'network error',
285
+ };
286
+ }
287
+ if (!res.ok) {
288
+ const errBody = (await res.json().catch(() => null));
289
+ const detail = errBody && typeof errBody.error === 'string'
290
+ ? ` — ${(0, sanitize_1.sanitizeField)(errBody.error)}`
291
+ : '';
292
+ return { status: 'error', reason: `HTTP ${res.status}${detail}` };
293
+ }
294
+ let data;
295
+ try {
296
+ data = (await res.json());
297
+ }
298
+ catch {
299
+ return { status: 'error', reason: 'invalid response body' };
300
+ }
301
+ if (typeof data.receipt !== 'string' || data.receipt.length === 0) {
302
+ return { status: 'error', reason: 'no receipt in response' };
303
+ }
304
+ const str = (v) => (typeof v === 'string' ? v : '');
305
+ return {
306
+ status: 'ok',
307
+ receipt: data.receipt,
308
+ expiresAt: str(data.expiresAt),
309
+ readAt: str(data.readAt),
310
+ readAcknowledgedAt: typeof data.readAcknowledgedAt === 'string'
311
+ ? data.readAcknowledgedAt
312
+ : null,
313
+ };
314
+ }
315
+ /** One task-scoped read of the LUM-435 view; the crossing picked by id. Fails
316
+ * closed: transport / non-ok / unparseable → error, never "not found". */
317
+ async function readCrossing(base, headers, taskId, crossingId) {
318
+ let res;
319
+ try {
320
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings`, { headers });
321
+ }
322
+ catch (err) {
323
+ return {
324
+ status: 'error',
325
+ reason: err instanceof Error ? err.message : 'network error',
326
+ };
327
+ }
328
+ if (res.status === 401) {
329
+ return { status: 'error', reason: 'API key invalid or revoked' };
330
+ }
331
+ if (!res.ok)
332
+ return { status: 'error', reason: `HTTP ${res.status}` };
333
+ let data;
334
+ try {
335
+ data = (await res.json());
336
+ }
337
+ catch {
338
+ return { status: 'error', reason: 'invalid response body' };
339
+ }
340
+ const rows = Array.isArray(data.crossings) ? data.crossings : [];
341
+ return { status: 'ok', crossing: rows.find(c => c.id === crossingId) ?? null };
342
+ }
@@ -4,14 +4,16 @@ exports.crossingExplain = crossingExplain;
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 bound_task_1 = require("../lib/bound-task");
7
8
  /**
8
9
  * `lumo crossing explain <id> --note "…"` — append an agent self-explanation
9
10
  * ("申辩") to a boundary crossing (LUM-542).
10
11
  *
11
12
  * This is the AGENT side of the boundary-crossing review loop and the deliberate
12
13
  * inverse of dispositioning: it can only ADD an append-only note for the human
13
- * reviewer to weigh — it never clears the crossing or unblocks Done (a human
14
- * dispositions that, in the web acceptance panel). The crossing must belong to
14
+ * reviewer to weigh — it never clears the crossing or unblocks Done (the user
15
+ * rules on that: `lumo crossing disposition` through the exit-4 confirmation
16
+ * protocol, or the web acceptance panel). The crossing must belong to
15
17
  * the task this session is bound to; the binding is how the target task is
16
18
  * resolved, so run it inside a session attached via `lumo session attach`.
17
19
  */
@@ -39,24 +41,9 @@ async function crossingExplain(crossingId, options = {}) {
39
41
  headers['X-Lumo-Session-Id'] = sessionId;
40
42
  // The crossing is addressed by id, but the route is task-scoped — resolve the
41
43
  // bound task from the session so the server can verify the crossing is on it.
42
- if (!sessionId) {
43
- console.error('Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`.');
44
- return 1;
45
- }
46
- let bound;
47
- try {
48
- const res = await fetch(`${base}/api/sessions/${encodeURIComponent(sessionId)}`, { headers });
49
- bound = res.ok
50
- ? (await res.json())
51
- : null;
52
- }
53
- catch (err) {
54
- const msg = err instanceof Error ? err.message : String(err);
55
- console.error(`Error: could not reach Lumo API (${msg})`);
56
- return 1;
57
- }
58
- if (!bound?.taskIdentifier) {
59
- console.error('Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first.');
44
+ const bound = await (0, bound_task_1.resolveBoundTask)({ base, headers, sessionId });
45
+ if (!bound.ok) {
46
+ console.error(bound.message);
60
47
  return 1;
61
48
  }
62
49
  const taskId = bound.taskIdentifier;
@@ -88,6 +75,8 @@ async function crossingExplain(crossingId, options = {}) {
88
75
  const outcome = (await res.json());
89
76
  process.stdout.write(`✓ Recorded an explanation on crossing ${(0, sanitize_1.sanitizeField)(outcome.crossingId)}.\n` +
90
77
  ' This is an append-only note for the human reviewer — it does not clear ' +
91
- 'the crossing or unblock Done.\n');
78
+ 'the crossing or unblock Done. Once the user rules, record it with ' +
79
+ '`lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope) ' +
80
+ 'or in the web panel.\n');
92
81
  return;
93
82
  }
@@ -83,7 +83,7 @@ function formatEntry(entry) {
83
83
  }
84
84
  function formatMilestoneChangelog(cl) {
85
85
  const lines = [
86
- `# Changelog — ${(0, sanitize_1.sanitizeField)(cl.milestone.name)} (${cl.milestone.status})`,
86
+ `# Changelog — ${(0, sanitize_1.sanitizeField)(cl.milestone.name)} (${cl.milestone.state})`,
87
87
  ];
88
88
  const window = formatWindow(cl.milestone.startDate, cl.milestone.targetDate);
89
89
  if (window)
@@ -19,23 +19,30 @@ function formatDate(iso) {
19
19
  return '-';
20
20
  return iso.slice(0, 10);
21
21
  }
22
+ /** LUM-714: the milestone's state, folded from its two timestamps. */
23
+ function rowState(r) {
24
+ if (r.archivedAt)
25
+ return 'ARCHIVED';
26
+ return r.completedAt ? 'COMPLETED' : 'ACTIVE';
27
+ }
22
28
  /**
23
29
  * Render milestones as fixed-width rows:
24
- * <STATUS> <HEALTH> <target-date or -> <name>
30
+ * <STATE> <HEALTH> <target-date or -> <name>
25
31
  *
32
+ * STATE is ACTIVE / COMPLETED / ARCHIVED (LUM-714) — derived, never stored.
26
33
  * HEALTH is the target-date risk light (ON-TRACK / AT-RISK / OVERDUE), or `-`
27
- * when no light applies (terminal status or no target date).
34
+ * when no light applies (completed, archived, or no target date).
28
35
  *
29
36
  * Sorted server-side by targetDate asc nulls last, createdAt asc.
30
37
  */
31
38
  function formatMilestoneList(rows) {
32
39
  if (rows.length === 0)
33
40
  return 'No milestones.';
34
- const statusW = Math.max(...rows.map(r => r.status.length));
41
+ const stateW = Math.max(...rows.map(r => rowState(r).length));
35
42
  const healthW = Math.max(...rows.map(r => formatHealth(r.health).length));
36
43
  const dateW = Math.max(...rows.map(r => formatDate(r.targetDate).length));
37
44
  return rows
38
- .map(r => `${r.status.padEnd(statusW)} ${formatHealth(r.health).padEnd(healthW)} ${formatDate(r.targetDate).padEnd(dateW)} ${r.archivedAt ? `${(0, sanitize_1.sanitizeField)(r.name)} (archived)` : (0, sanitize_1.sanitizeField)(r.name)}`)
45
+ .map(r => `${rowState(r).padEnd(stateW)} ${formatHealth(r.health).padEnd(healthW)} ${formatDate(r.targetDate).padEnd(dateW)} ${(0, sanitize_1.sanitizeField)(r.name)}`)
39
46
  .join('\n');
40
47
  }
41
48
  async function milestoneList(options) {
@@ -65,15 +65,25 @@ function formatMilestoneShow(m, tasks) {
65
65
  m.taskCounts.IN_PROGRESS +
66
66
  m.taskCounts.IN_REVIEW +
67
67
  m.taskCounts.DONE;
68
- const statusLine = m.staleness?.statusDrift
69
- ? `Status: ${m.status} (staletask progress indicates ${(0, sanitize_1.sanitizeField)(m.staleness.statusDrift)}; auto-updating)`
70
- : `Status: ${m.status}`;
68
+ // LUM-714: state is derived, so the line reports what the two timestamps
69
+ // sayand, when the tasks disagree with the stored stamp, what the rollup
70
+ // is about to do about it.
71
+ const state = m.archivedAt
72
+ ? 'archived'
73
+ : m.completedAt
74
+ ? 'completed'
75
+ : 'active';
76
+ const drift = m.staleness?.completionDrift;
77
+ const stateLine = drift
78
+ ? `State: ${state} (stale — task progress indicates ${drift === 'complete' ? 'completed' : 'active'}; auto-updating)`
79
+ : `State: ${state}`;
71
80
  const targetLine = m.staleness?.datesMissing
72
81
  ? `Target: ${fmtDate(m.targetDate)} (no target date — schedule health unavailable)`
73
82
  : `Target: ${fmtDate(m.targetDate)}`;
74
83
  const lines = [
75
84
  `Milestone: ${(0, sanitize_1.sanitizeField)(m.name)}`,
76
- statusLine,
85
+ stateLine,
86
+ `Completed: ${m.completedAt ? m.completedAt.slice(0, 10) : 'no'}`,
77
87
  `Archived: ${m.archivedAt ? m.archivedAt.slice(0, 10) : 'no'}`,
78
88
  `Health: ${fmtHealth(m.health)}`,
79
89
  `Start: ${fmtDate(m.startDate)}`,
@@ -156,7 +166,7 @@ async function milestoneShow(identifier, opts) {
156
166
  process.stdout.write(formatMilestoneShow({
157
167
  id: milestone.id,
158
168
  name: milestone.name,
159
- status: milestone.status,
169
+ completedAt: milestone.completedAt,
160
170
  startDate: milestone.startDate,
161
171
  targetDate: milestone.targetDate,
162
172
  archivedAt: milestone.archivedAt,
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeMilestoneStatus = normalizeMilestoneStatus;
4
3
  exports.normalizeTokenBudget = normalizeTokenBudget;
5
4
  exports.buildMilestoneUpdatePayload = buildMilestoneUpdatePayload;
6
5
  exports.formatMilestoneUpdateSummary = formatMilestoneUpdateSummary;
@@ -9,20 +8,6 @@ const config_1 = require("../lib/config");
9
8
  const api_1 = require("../lib/api");
10
9
  const resolve_1 = require("../lib/resolve");
11
10
  const sanitize_1 = require("../lib/sanitize");
12
- const ALLOWED_STATUSES = [
13
- 'PLANNED',
14
- 'ACTIVE',
15
- 'COMPLETED',
16
- 'CANCELLED',
17
- ];
18
- function normalizeMilestoneStatus(value) {
19
- if (!value)
20
- return null;
21
- const upper = value.toUpperCase().replace(/-/g, '_');
22
- return ALLOWED_STATUSES.includes(upper)
23
- ? upper
24
- : null;
25
- }
26
11
  /**
27
12
  * LUM-644: '' clears the budget (→ null, the nullable-flag convention);
28
13
  * otherwise the value must be a positive integer within Postgres INT range.
@@ -49,10 +34,6 @@ function buildMilestoneUpdatePayload(opts) {
49
34
  payload.description = opts.description === '' ? null : opts.description;
50
35
  flagsGiven.push('--description');
51
36
  }
52
- if (opts.status !== undefined) {
53
- payload.status = opts.status;
54
- flagsGiven.push('--status');
55
- }
56
37
  if (opts.start !== undefined) {
57
38
  payload.startDate = opts.start === '' ? null : opts.start;
58
39
  flagsGiven.push('--start');
@@ -85,9 +66,6 @@ function formatMilestoneUpdateSummary(before, after) {
85
66
  if (after.description !== undefined) {
86
67
  changes.push(after.description === null ? 'description → ∅' : 'description updated');
87
68
  }
88
- if (after.status !== undefined && after.status !== before.status) {
89
- changes.push(`status ${before.status} → ${after.status}`);
90
- }
91
69
  if (after.startDate !== undefined) {
92
70
  changes.push(`start → ${fmtDate(after.startDate)}`);
93
71
  }
@@ -102,28 +80,15 @@ function formatMilestoneUpdateSummary(before, after) {
102
80
  return `Updated milestone "${beforeName}": ${changes.join(', ')}`;
103
81
  }
104
82
  async function milestoneUpdate(identifier, opts) {
105
- // Validate status flag eagerly.
106
- let normalizedStatus;
107
- if (opts.status !== undefined) {
108
- const n = normalizeMilestoneStatus(opts.status);
109
- if (!n) {
110
- console.error(`Error: invalid status "${opts.status}". Allowed: planned, active, completed, cancelled`);
111
- return 1;
112
- }
113
- normalizedStatus = n;
114
- }
115
83
  // Validate token budget eagerly (LUM-644): positive integer, or '' to clear.
116
84
  if (opts.tokenBudget !== undefined &&
117
85
  normalizeTokenBudget(opts.tokenBudget) === undefined) {
118
86
  console.error(`Error: invalid token budget "${opts.tokenBudget}". Provide a positive integer (e.g. 5000000), or "" to clear.`);
119
87
  return 1;
120
88
  }
121
- const { payload, flagsGiven } = buildMilestoneUpdatePayload({
122
- ...opts,
123
- ...(normalizedStatus !== undefined && { status: normalizedStatus }),
124
- });
89
+ const { payload, flagsGiven } = buildMilestoneUpdatePayload(opts);
125
90
  if (flagsGiven.length === 0) {
126
- console.error('Error: provide at least one field to update (--name, --description, --status, --start, --target, --token-budget)');
91
+ console.error('Error: provide at least one field to update (--name, --description, --start, --target, --token-budget)');
127
92
  return 1;
128
93
  }
129
94
  const creds = (0, config_1.readCredentials)();
@@ -64,7 +64,7 @@ function formatTaskContextMarkdown(data, now) {
64
64
  const target = data.task.milestone.targetDate
65
65
  ? `, target ${data.task.milestone.targetDate.slice(0, 10)}`
66
66
  : '';
67
- lines.push(`**Milestone**: ${(0, sanitize_1.sanitizeField)(data.task.milestone.name)} (${data.task.milestone.status}${target})`);
67
+ lines.push(`**Milestone**: ${(0, sanitize_1.sanitizeField)(data.task.milestone.name)} (${data.task.milestone.state}${target})`);
68
68
  const milestoneGoal = data.task.milestone.description;
69
69
  if (milestoneGoal && milestoneGoal.trim().length > 0) {
70
70
  lines.push(`**Milestone goal**: ${(0, sanitize_1.sanitizeField)(milestoneGoal)}`);