@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.
- package/assets/skill/SKILL.md +5 -4
- package/assets/skill/references/confirmation.md +24 -21
- package/assets/skill/references/milestones.md +14 -11
- package/assets/skill/references/sessions.md +1 -1
- package/assets/skill/references/tasks.md +1 -1
- package/assets/skill/references/verify.md +27 -5
- package/dist/cli/src/commands/crossing-disposition.js +342 -0
- package/dist/cli/src/commands/crossing-explain.js +10 -21
- package/dist/cli/src/commands/milestone-changelog.js +1 -1
- package/dist/cli/src/commands/milestone-list.js +11 -4
- package/dist/cli/src/commands/milestone-show.js +15 -5
- package/dist/cli/src/commands/milestone-update.js +2 -37
- package/dist/cli/src/commands/task-context.js +1 -1
- package/dist/cli/src/commands/task-status.js +57 -18
- package/dist/cli/src/commands/task-update.js +2 -1
- package/dist/cli/src/index.js +14 -3
- package/dist/cli/src/lib/blocked-error.js +44 -17
- package/dist/cli/src/lib/bound-task.js +32 -0
- package/dist/cli/src/lib/confirmation.js +38 -8
- package/dist/cli/src/lib/open-crossings.js +35 -15
- package/dist/shared/src/security-scan.js +97 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
14
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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.
|
|
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
|
-
* <
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// LUM-714: state is derived, so the line reports what the two timestamps
|
|
69
|
+
// say — and, 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
|
-
|
|
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
|
-
|
|
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, --
|
|
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.
|
|
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)}`);
|