@lumoai/cli 1.58.0 → 1.60.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 +15 -17
- package/assets/skill/references/artifacts-figma.md +4 -3
- package/assets/skill/references/confirmation.md +133 -0
- package/assets/skill/references/criteria.md +12 -21
- package/assets/skill/references/doc-editing.md +11 -9
- package/assets/skill/references/docs.md +4 -3
- package/assets/skill/references/memory.md +4 -2
- package/assets/skill/references/milestones.md +3 -2
- package/assets/skill/references/outcome.md +1 -14
- package/assets/skill/references/plan-runs.md +5 -0
- package/assets/skill/references/sessions.md +4 -4
- package/assets/skill/references/sprints.md +18 -17
- package/assets/skill/references/task-deps.md +4 -3
- package/assets/skill/references/tasks.md +34 -2
- package/assets/skill/references/verify.md +71 -71
- package/assets/skill/references/worktree.md +13 -7
- 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/doc-delete.js +35 -23
- package/dist/cli/src/commands/doc-rebuild-source.js +16 -4
- package/dist/cli/src/commands/memory-rm.js +68 -10
- package/dist/cli/src/commands/milestone-delete.js +13 -10
- package/dist/cli/src/commands/outcome.js +0 -77
- package/dist/cli/src/commands/session-attach.js +8 -2
- package/dist/cli/src/commands/sprint-close.js +29 -9
- package/dist/cli/src/commands/sprint-delete.js +13 -10
- package/dist/cli/src/commands/sprint-show.js +3 -9
- package/dist/cli/src/commands/task-artifact-rm.js +58 -28
- package/dist/cli/src/commands/task-criteria-list.js +1 -4
- package/dist/cli/src/commands/task-criteria-set.js +3 -12
- package/dist/cli/src/commands/task-deps.js +20 -6
- package/dist/cli/src/commands/task-status.js +196 -111
- package/dist/cli/src/commands/task-update.js +129 -0
- package/dist/cli/src/commands/verify.js +22 -13
- package/dist/cli/src/commands/worktree-rm.js +35 -7
- package/dist/cli/src/index.js +60 -48
- package/dist/cli/src/lib/blocked-error.js +183 -0
- package/dist/cli/src/lib/bound-task.js +32 -0
- package/dist/cli/src/lib/confirmation.js +119 -0
- package/dist/cli/src/lib/hook-runner.js +23 -11
- package/dist/cli/src/lib/open-crossings.js +6 -6
- package/dist/shared/src/referent-kind.js +31 -1
- package/dist/shared/src/security-scan.js +125 -0
- package/package.json +1 -1
- package/assets/skill/references/fidelity.md +0 -32
- package/dist/cli/src/commands/fidelity.js +0 -108
- package/dist/cli/src/commands/verdict.js +0 -189
|
@@ -6,6 +6,7 @@ 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 security_scan_1 = require("../../../shared/src/security-scan");
|
|
9
10
|
const open_crossings_1 = require("../lib/open-crossings");
|
|
10
11
|
const evidence_display_1 = require("../lib/evidence-display");
|
|
11
12
|
/** One-line a possibly-multiline crossing detail and cap it so the safety block
|
|
@@ -22,6 +23,141 @@ const REASON_TAIL = 400;
|
|
|
22
23
|
function tail(s, max) {
|
|
23
24
|
return s.length > max ? `…${s.slice(-max)}` : s;
|
|
24
25
|
}
|
|
26
|
+
/** A missing `kind` is an older server's criterion entry (LUM-737 additive rule). */
|
|
27
|
+
function isSecurityAction(a) {
|
|
28
|
+
return a.kind === 'SECURITY_FINDING';
|
|
29
|
+
}
|
|
30
|
+
function splitNextActions(actions) {
|
|
31
|
+
const criteria = [];
|
|
32
|
+
const security = [];
|
|
33
|
+
for (const a of actions) {
|
|
34
|
+
if (isSecurityAction(a))
|
|
35
|
+
security.push(a);
|
|
36
|
+
else
|
|
37
|
+
criteria.push(a);
|
|
38
|
+
}
|
|
39
|
+
return { criteria, security };
|
|
40
|
+
}
|
|
41
|
+
/** Same line grammar as the session-start / PreToolUse reminders. */
|
|
42
|
+
function formatSecurityAction(a) {
|
|
43
|
+
const where = a.line == null ? a.filePath : `${a.filePath}:${a.line}`;
|
|
44
|
+
const title = (0, sanitize_1.sanitizeField)(a.statement.split(' — ').slice(1).join(' — '));
|
|
45
|
+
const tag = a.blocking ? 'blocks DONE' : 'advisory';
|
|
46
|
+
// LUM-758 follow-up: a downgraded row is still reported on purpose, so the
|
|
47
|
+
// line has to say WHY it is LOW — otherwise a placeholder the scanner already
|
|
48
|
+
// recognised reads as an unexplained credential.
|
|
49
|
+
const note = (0, sanitize_1.sanitizeField)((0, security_scan_1.secretDowngradeNote)(a.secretDowngrade));
|
|
50
|
+
return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} — ${(0, sanitize_1.sanitizeField)(where)} — ${title} (PR #${a.prNumber} · ${tag}${note})`;
|
|
51
|
+
}
|
|
52
|
+
const SECURITY_HINT = 'Fix the findings and push (a fixed finding disappears on the next scan), or ask a human to disposition them in the web delivery panel — disposition is human-only, you cannot disposition them yourself, and a blocking finding refuses DONE with 409.';
|
|
53
|
+
/** Render the `[SECURITY]` next-action lines (LUM-737 review): shared by the
|
|
54
|
+
* criteria-present Next actions block and the zero-criteria early return so
|
|
55
|
+
* undispositioned findings render identically either way. */
|
|
56
|
+
function pushSecurityActions(lines, securityActions) {
|
|
57
|
+
for (const a of securityActions) {
|
|
58
|
+
lines.push(` • [SECURITY] ${formatSecurityAction(a)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Fail-closed security coverage lines (LUM-737): a failed read and every
|
|
62
|
+
* open PR without a successful scan are stated, never left silent. */
|
|
63
|
+
const EXTERNAL_ERROR_TAIL = 200;
|
|
64
|
+
const SCAN_STAGE_ORDER = security_scan_1.SCAN_STAGE_KEYS;
|
|
65
|
+
/** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
|
|
66
|
+
* Secrets: checked · Code scan: checked (2 external findings) ·
|
|
67
|
+
* Dependencies: checked (1 dependency finding, 1 already on main) ·
|
|
68
|
+
* AI review: checked · Exploit paths: checked`.
|
|
69
|
+
* Stage segments only for keys present, in
|
|
70
|
+
* secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
|
|
71
|
+
* layer); the `(N external findings)` count only decorates the Code scan
|
|
72
|
+
* segment, the dependency count (LUM-738, with its already-on-main share)
|
|
73
|
+
* only the Dependencies segment, and each only when N > 0.
|
|
74
|
+
* LUM-763: the stage keys are internal — what prints is the display label
|
|
75
|
+
* from `shared/src/security-scan.ts`, the same map the GitHub PR summary
|
|
76
|
+
* and the web panel render from. */
|
|
77
|
+
function formatScanSummaryLine(s) {
|
|
78
|
+
const segments = [];
|
|
79
|
+
for (const key of SCAN_STAGE_ORDER) {
|
|
80
|
+
const state = s.stages[key];
|
|
81
|
+
if (!state)
|
|
82
|
+
continue;
|
|
83
|
+
const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
|
|
84
|
+
if (key === 'external' && s.externalFindings > 0) {
|
|
85
|
+
const noun = s.externalFindings === 1 ? 'finding' : 'findings';
|
|
86
|
+
segments.push(`${head} (${s.externalFindings} external ${noun})`);
|
|
87
|
+
}
|
|
88
|
+
else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
|
|
89
|
+
const n = s.dependencyFindings ?? 0;
|
|
90
|
+
const noun = n === 1 ? 'finding' : 'findings';
|
|
91
|
+
const persisting = s.persistingFindings ?? 0;
|
|
92
|
+
const tail = persisting > 0 ? `, ${persisting} already on main` : '';
|
|
93
|
+
segments.push(`${head} (${n} dependency ${noun}${tail})`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
segments.push(head);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let line = ` PR #${s.prNumber} · scan ${s.status}`;
|
|
100
|
+
if (segments.length > 0)
|
|
101
|
+
line += ` · ${segments.join(' · ')}`;
|
|
102
|
+
if (s.partial)
|
|
103
|
+
line += ' · partial';
|
|
104
|
+
// LUM-756 (P8): `error` has two writers — only the prefixed, scrubbed
|
|
105
|
+
// scanner reason may be printed, and only the text after the prefix. Stage
|
|
106
|
+
// A's raw crash text carries no prefix and is never shown here (same rule
|
|
107
|
+
// as the web panel and the PR summary).
|
|
108
|
+
const reason = s.stages.external === 'FAILED' ? (0, security_scan_1.externalFailureReason)(s.error) : null;
|
|
109
|
+
if (reason) {
|
|
110
|
+
line += ` — ${(0, sanitize_1.sanitizeField)(tail(reason, EXTERNAL_ERROR_TAIL))}`;
|
|
111
|
+
}
|
|
112
|
+
return line;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* LUM-762 — the hunt's per-task read-out, one indented line under its scan:
|
|
116
|
+
* `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
|
|
117
|
+
* (2 attempts) · t3 race skipped`. Headed by the same display label as the
|
|
118
|
+
* layer's own segment (LUM-763) — the detail line and the line above it
|
|
119
|
+
* must not name the layer two different ways. This is the only place the stored `huntAudit` surfaces, so
|
|
120
|
+
* it is what answers "how long does a healthy batch take" and "which ones
|
|
121
|
+
* never ran" (`skipped`) without paging through deploy logs. Elapsed time is
|
|
122
|
+
* summed over a task's attempts; a skipped task has none to print.
|
|
123
|
+
*
|
|
124
|
+
* LUM-758: a plan may hold two tasks of the same category, so the row is led
|
|
125
|
+
* by its task id. Pre-task-graph audits carry no id — the row then prints
|
|
126
|
+
* exactly as it always did.
|
|
127
|
+
*/
|
|
128
|
+
function formatHuntCoverageLine(hunt) {
|
|
129
|
+
const parts = hunt.categories.map(c => {
|
|
130
|
+
const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
|
|
131
|
+
const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
|
|
132
|
+
const id = c.taskId === undefined ? '' : `${c.taskId} `;
|
|
133
|
+
return `${id}${c.category} ${spent}${c.stopped}${attempts}`;
|
|
134
|
+
});
|
|
135
|
+
return ` ${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
|
|
136
|
+
}
|
|
137
|
+
function pushSecurityCoverage(lines, data) {
|
|
138
|
+
if (data.securityFindings === undefined)
|
|
139
|
+
return; // older server
|
|
140
|
+
if (data.securityFindings === null) {
|
|
141
|
+
lines.push('⚠ Security-scan check failed — could not confirm whether any findings are undispositioned.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// LUM-735: absent `scans` = older server; tolerate silently, no block.
|
|
145
|
+
for (const s of data.securityFindings.scans ?? []) {
|
|
146
|
+
lines.push(formatScanSummaryLine(s));
|
|
147
|
+
// LUM-762: absent/null = older server or no usable audit — stay silent.
|
|
148
|
+
if (s.hunt && s.hunt.categories.length > 0) {
|
|
149
|
+
lines.push(formatHuntCoverageLine(s.hunt));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const u of data.securityFindings.unconfirmedPrs) {
|
|
153
|
+
const why = u.reason === 'NONE'
|
|
154
|
+
? 'no security scan has run'
|
|
155
|
+
: u.reason === 'FAILED'
|
|
156
|
+
? 'security scan FAILED'
|
|
157
|
+
: `security scan still ${u.reason}`;
|
|
158
|
+
lines.push(`⚠ PR #${u.number}: ${why} — could not confirm it is clean.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
25
161
|
/**
|
|
26
162
|
* Render the acceptance status as prose. Same row grammar as
|
|
27
163
|
* `criteria list` (`<id> [TYPE] SOURCE@rN statement`) with a verdict
|
|
@@ -39,20 +175,35 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
39
175
|
if (data.escalated) {
|
|
40
176
|
lines.push('⚠ Escalated: the machine loop is exhausted — a human has been paged. Stop retrying lumo verify.');
|
|
41
177
|
}
|
|
178
|
+
// LUM-737 (whole-branch review): split once, up front, so both the
|
|
179
|
+
// zero-criteria early return below and the --full rollup can render/count
|
|
180
|
+
// undispositioned security findings without either dropping them or
|
|
181
|
+
// subtracting them from the criteria-met arithmetic.
|
|
182
|
+
const { criteria: unmetCriteria, security: securityActions } = splitNextActions(data.nextActions);
|
|
42
183
|
if (data.criteria.length === 0) {
|
|
43
184
|
lines.push('');
|
|
44
|
-
lines.push(`No acceptance criteria on ${t.identifier} — draft
|
|
185
|
+
lines.push(`No acceptance criteria on ${t.identifier} — draft them and submit with lumo task criteria set ${t.identifier} --file <criteria.json>`);
|
|
186
|
+
if (securityActions.length > 0) {
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push(`Next actions (0 unmet · ${securityActions.length} security finding${securityActions.length === 1 ? '' : 's'}):`);
|
|
189
|
+
pushSecurityActions(lines, securityActions);
|
|
190
|
+
lines.push(SECURITY_HINT);
|
|
191
|
+
}
|
|
192
|
+
pushSecurityCoverage(lines, data);
|
|
45
193
|
pushOpenCrossings(lines, extras);
|
|
46
194
|
return lines.join('\n') + '\n';
|
|
47
195
|
}
|
|
48
|
-
// LUM-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
196
|
+
// LUM-733: the default report is the agent's self-check — criteria, next
|
|
197
|
+
// actions (including undispositioned security findings, LUM-737), and any
|
|
198
|
+
// open crossings. The dashboard sections (verification rollup, history,
|
|
199
|
+
// cost, struggle trail, trend) are human-dashboard material and cost
|
|
200
|
+
// context, so they only render under --full. The claim / faithfulness
|
|
201
|
+
// columns are no longer rendered in the terminal at all (the judge still
|
|
202
|
+
// runs server-side; --json carries its fields).
|
|
203
|
+
if (extras.full)
|
|
204
|
+
pushVerificationRollup(lines, unmetCriteria.length, data);
|
|
54
205
|
lines.push('');
|
|
55
|
-
lines.push(`Criteria (${data.criteria.length} total, ${
|
|
206
|
+
lines.push(`Criteria (${data.criteria.length} total, ${unmetCriteria.length} unmet):`);
|
|
56
207
|
for (const c of data.criteria) {
|
|
57
208
|
const glyph = c.latestVerdict == null
|
|
58
209
|
? '○'
|
|
@@ -127,7 +278,7 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
127
278
|
}
|
|
128
279
|
}
|
|
129
280
|
}
|
|
130
|
-
if (data.verificationHistory.length > 0) {
|
|
281
|
+
if (extras.full && data.verificationHistory.length > 0) {
|
|
131
282
|
lines.push('');
|
|
132
283
|
lines.push('History:');
|
|
133
284
|
for (const h of data.verificationHistory) {
|
|
@@ -144,18 +295,23 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
144
295
|
}
|
|
145
296
|
}
|
|
146
297
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
298
|
+
if (extras.full) {
|
|
299
|
+
pushCost(lines, data);
|
|
300
|
+
pushStruggleTrail(lines, data);
|
|
301
|
+
pushTrend(lines, data);
|
|
302
|
+
}
|
|
150
303
|
lines.push('');
|
|
151
|
-
|
|
304
|
+
const securityLabel = securityActions.length === 0
|
|
305
|
+
? ''
|
|
306
|
+
: ` · ${securityActions.length} security finding${securityActions.length === 1 ? '' : 's'}`;
|
|
307
|
+
if (unmetCriteria.length === 0 && securityActions.length === 0) {
|
|
152
308
|
lines.push(data.currentRound > 0
|
|
153
309
|
? 'All criteria met by their latest verdicts — awaiting human adjudication.'
|
|
154
310
|
: 'Nothing unmet — but no verification has run; run `lumo verify` to judge the contract.');
|
|
155
311
|
}
|
|
156
312
|
else {
|
|
157
|
-
lines.push(`Next actions (${
|
|
158
|
-
for (const a of
|
|
313
|
+
lines.push(`Next actions (${unmetCriteria.length} unmet${securityLabel}):`);
|
|
314
|
+
for (const a of unmetCriteria) {
|
|
159
315
|
lines.push(` • [${a.verifierType}] ${(0, sanitize_1.sanitizeField)(a.statement)}`);
|
|
160
316
|
if (a.checkpointer) {
|
|
161
317
|
lines.push(` check: ${(0, sanitize_1.sanitizeField)(a.checkpointer)}`);
|
|
@@ -164,17 +320,22 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
164
320
|
lines.push(` why: ${(0, sanitize_1.sanitizeField)(tail(a.rejectionReason, REASON_TAIL))}`);
|
|
165
321
|
}
|
|
166
322
|
}
|
|
323
|
+
// LUM-737: findings after the contract — constraints on DONE, not criteria.
|
|
324
|
+
pushSecurityActions(lines, securityActions);
|
|
167
325
|
if (data.escalated) {
|
|
168
326
|
lines.push('Wait for human direction before touching these.');
|
|
169
327
|
}
|
|
170
|
-
else {
|
|
171
|
-
const hasMachine =
|
|
328
|
+
else if (unmetCriteria.length > 0) {
|
|
329
|
+
const hasMachine = unmetCriteria.some(a => a.verifierType === 'MACHINE');
|
|
172
330
|
const left = data.maxRounds - data.currentRound;
|
|
173
331
|
lines.push(hasMachine
|
|
174
332
|
? `Fix the unmet criteria, then run \`lumo verify\` (${left} round${left === 1 ? '' : 's'} left).`
|
|
175
333
|
: 'Remaining criteria are HUMAN-only — finish the work and hand off for human review.');
|
|
176
334
|
}
|
|
335
|
+
if (securityActions.length > 0)
|
|
336
|
+
lines.push(SECURITY_HINT);
|
|
177
337
|
}
|
|
338
|
+
pushSecurityCoverage(lines, data);
|
|
178
339
|
pushOpenCrossings(lines, extras);
|
|
179
340
|
return lines.join('\n') + '\n';
|
|
180
341
|
}
|
|
@@ -222,104 +383,27 @@ function fmtDuration(totalSec) {
|
|
|
222
383
|
* claim text is same-source with the web card (latest session's LLM run
|
|
223
384
|
* summary, else its STOP turn digest) so the two surfaces cannot drift.
|
|
224
385
|
*/
|
|
225
|
-
|
|
386
|
+
/**
|
|
387
|
+
* LUM-733: the measured verification rollup (--full only) — machine-verified
|
|
388
|
+
* vs human-override over the MACHINE criteria, and how many criteria are met.
|
|
389
|
+
* The former "Claim vs verification" pairing (claim text + faithfulness
|
|
390
|
+
* verdict) is no longer rendered in the terminal.
|
|
391
|
+
*/
|
|
392
|
+
function pushVerificationRollup(lines, unmetCriteriaCount, data) {
|
|
226
393
|
lines.push('');
|
|
227
|
-
lines.push('
|
|
228
|
-
// ── Claim (声称): the agent's self-report — estimate-tier, never measured.
|
|
229
|
-
lines.push(' ▸ Claim — what the agent says it did');
|
|
230
|
-
lines.push(' (agent self-report · estimated, not verification):');
|
|
231
|
-
const claim = data.claim;
|
|
232
|
-
if (claim && claim.source === 'AGENT' && claim.text) {
|
|
233
|
-
// LUM-597: the agent's OWN self-report (attached to verify) — the
|
|
234
|
-
// authoritative claim faithfulness judges, not a summarizer paraphrase.
|
|
235
|
-
for (const cl of (0, sanitize_1.sanitizeField)(claim.text).split('\n')) {
|
|
236
|
-
lines.push(` ${cl}`);
|
|
237
|
-
}
|
|
238
|
-
lines.push(' ↳ source: agent self-report (verify --note)');
|
|
239
|
-
}
|
|
240
|
-
else if (claim && claim.source === 'RUN_SUMMARY' && claim.text) {
|
|
241
|
-
for (const cl of (0, sanitize_1.sanitizeField)(claim.text).split('\n')) {
|
|
242
|
-
lines.push(` ${cl}`);
|
|
243
|
-
}
|
|
244
|
-
lines.push(' ↳ source: synthesized run summary (no self-report)');
|
|
245
|
-
}
|
|
246
|
-
else if (claim && claim.source === 'DIGEST') {
|
|
247
|
-
// LUM-583 ③ / LUM-574: a raw turn digest is not a claim — withhold it and
|
|
248
|
-
// say the formal summary is still generating, same-source with the web card.
|
|
249
|
-
lines.push(' generating — the formal run summary is still being synthesized');
|
|
250
|
-
}
|
|
251
|
-
else {
|
|
252
|
-
// Fail-closed: no run summary → say so, never invent a claim. Run summaries
|
|
253
|
-
// are synthesized when the bound task reaches DONE (LUM-481).
|
|
254
|
-
lines.push(' not generated yet — the agent run summary is synthesized when the task reaches DONE');
|
|
255
|
-
}
|
|
256
|
-
// ── Faithfulness (LUM-583): the third state — is the claim itself true? Read
|
|
257
|
-
// from the persisted LUM-582 verdict; PENDING (not yet judged) is distinct
|
|
258
|
-
// from UNJUDGEABLE (a real verdict), STALE keeps the verdict but flags a
|
|
259
|
-
// pending re-check. Skipped only when the server didn't emit it (older server).
|
|
260
|
-
pushFaithfulness(lines, data.faithfulness);
|
|
261
|
-
// ── Verification (核验): the measured verdict — machine-verified vs override.
|
|
262
|
-
lines.push(' ▸ Verification — what was actually confirmed (measured):');
|
|
394
|
+
lines.push('Verification:');
|
|
263
395
|
const mv = data.machineVerification;
|
|
264
396
|
if (data.currentRound === 0) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
if (mv.total > 0) {
|
|
270
|
-
lines.push(` ${mv.machineVerified} machine-verified / ${mv.humanOverridden} human override (of ${mv.total} MACHINE criteria)`);
|
|
271
|
-
}
|
|
272
|
-
const met = data.criteria.length - data.nextActions.length;
|
|
273
|
-
lines.push(` ${met} of ${data.criteria.length} criteria met by their latest verdict`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Append the "Faithfulness" conclusion (LUM-583) — the third state over the
|
|
278
|
-
* 声称/核验 columns: whether the agent's CLAIM is itself TRUE, judged
|
|
279
|
-
* independently against the delivery (LUM-582). Same read model the web honest
|
|
280
|
-
* report renders, so the two surfaces cannot drift. Fail-closed: PENDING (not
|
|
281
|
-
* yet judged) reads distinctly from UNJUDGEABLE (a real verdict), and STALE
|
|
282
|
-
* keeps the prior verdict while flagging a pending re-check. Skipped only when
|
|
283
|
-
* the server didn't emit the field (older server) — never fabricated.
|
|
284
|
-
*/
|
|
285
|
-
function pushFaithfulness(lines, f) {
|
|
286
|
-
if (!f)
|
|
287
|
-
return; // older server: field absent → don't fabricate a conclusion.
|
|
288
|
-
const verdictPhrase = (v) => {
|
|
289
|
-
switch (v) {
|
|
290
|
-
case 'FAITHFUL':
|
|
291
|
-
return 'faithful — the claim matches the delivery';
|
|
292
|
-
case 'OVERSTATED':
|
|
293
|
-
return 'overstated — the claim says more than the diff/PR shows';
|
|
294
|
-
case 'UNDERREPORT':
|
|
295
|
-
return 'under-reported — the claim says less than the diff/PR shows';
|
|
296
|
-
default:
|
|
297
|
-
return "unjudgeable — couldn't decide from the diff/PR";
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
let body;
|
|
301
|
-
if (f.state === 'PENDING') {
|
|
302
|
-
body =
|
|
303
|
-
'not yet judged — the faithfulness check runs in a batch after delivery';
|
|
304
|
-
}
|
|
305
|
-
else if (f.state === 'STALE') {
|
|
306
|
-
body = `${f.verdict ? verdictPhrase(f.verdict) : 'judged'} (stale — re-checked on the next batch)`;
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
body = verdictPhrase(f.state);
|
|
310
|
-
}
|
|
311
|
-
const ev = [];
|
|
312
|
-
if (f.prNumbers.length > 0) {
|
|
313
|
-
ev.push(`PR ${f.prNumbers.map(n => `#${n}`).join(', ')}`);
|
|
397
|
+
lines.push(' no verification has run yet');
|
|
398
|
+
return;
|
|
314
399
|
}
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
.slice(0, 3)
|
|
318
|
-
.map(s => (0, sanitize_1.sanitizeField)(s).slice(0, 7))
|
|
319
|
-
.join(' '));
|
|
400
|
+
if (mv.total > 0) {
|
|
401
|
+
lines.push(` ${mv.machineVerified} machine-verified / ${mv.humanOverridden} human override (of ${mv.total} MACHINE criteria)`);
|
|
320
402
|
}
|
|
321
|
-
|
|
322
|
-
|
|
403
|
+
// LUM-737 (whole-branch review): `unmetCriteriaCount` excludes SECURITY_FINDING
|
|
404
|
+
// next actions — met/unmet counts criteria only, never security findings.
|
|
405
|
+
const met = data.criteria.length - unmetCriteriaCount;
|
|
406
|
+
lines.push(` ${met} of ${data.criteria.length} criteria met by their latest verdict`);
|
|
323
407
|
}
|
|
324
408
|
/**
|
|
325
409
|
* Append the honest "Cost" section (LUM-560) — 规律 1: surface the costs a human
|
|
@@ -535,7 +619,7 @@ function pushOpenCrossings(lines, extras) {
|
|
|
535
619
|
lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
|
|
536
620
|
lines.push(` ${formatAttribution(c.attribution)}`);
|
|
537
621
|
}
|
|
538
|
-
lines.push(
|
|
622
|
+
lines.push(" Disposition is the user's call: relay each crossing, then `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope; never self-approve), or the web acceptance panel:");
|
|
539
623
|
if (extras.dispositionUrl) {
|
|
540
624
|
lines.push(` ${extras.dispositionUrl}`);
|
|
541
625
|
}
|
|
@@ -619,6 +703,7 @@ async function taskStatus(identifier, options = {}) {
|
|
|
619
703
|
return;
|
|
620
704
|
}
|
|
621
705
|
process.stdout.write(formatTaskStatus(data, {
|
|
706
|
+
full: options.full === true,
|
|
622
707
|
openCrossings: crossingsResult,
|
|
623
708
|
dispositionUrl: (0, open_crossings_1.dispositionUrl)(base, creds.workspaceSlug ?? 'lumo', data.task.identifier),
|
|
624
709
|
// LUM-563: resolve the local repo's web base so a `commit:` evidence
|
|
@@ -4,6 +4,8 @@ exports.decideSprintAction = decideSprintAction;
|
|
|
4
4
|
exports.normalizeStatus = normalizeStatus;
|
|
5
5
|
exports.buildUpdatePayload = buildUpdatePayload;
|
|
6
6
|
exports.formatUpdatedTaskLine = formatUpdatedTaskLine;
|
|
7
|
+
exports.partitionLinkedPrs = partitionLinkedPrs;
|
|
8
|
+
exports.describeDoneTransition = describeDoneTransition;
|
|
7
9
|
exports.taskUpdate = taskUpdate;
|
|
8
10
|
const config_1 = require("../lib/config");
|
|
9
11
|
const api_1 = require("../lib/api");
|
|
@@ -12,6 +14,8 @@ const tag_resolver_1 = require("../lib/tag-resolver");
|
|
|
12
14
|
const resolve_1 = require("../lib/resolve");
|
|
13
15
|
const sanitize_1 = require("../lib/sanitize");
|
|
14
16
|
const next_steps_1 = require("../lib/next-steps");
|
|
17
|
+
const confirmation_1 = require("../lib/confirmation");
|
|
18
|
+
const blocked_error_1 = require("../lib/blocked-error");
|
|
15
19
|
const ALLOWED_STATUSES = ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'];
|
|
16
20
|
/**
|
|
17
21
|
* Pure function: given the task's current sprint binding and the resolved
|
|
@@ -102,6 +106,101 @@ function formatUpdatedTaskLine(task) {
|
|
|
102
106
|
}
|
|
103
107
|
return head;
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Split linked PRs into merged vs everything else. "Unmerged" is deliberately
|
|
111
|
+
* broad — open, closed-without-merge, draft (state "open"), or an unknown
|
|
112
|
+
* state all count — because the question the ⚠ line asks is "are you sure
|
|
113
|
+
* the work actually landed?", and only `merged` answers yes.
|
|
114
|
+
*/
|
|
115
|
+
function partitionLinkedPrs(prs) {
|
|
116
|
+
const merged = [];
|
|
117
|
+
const unmerged = [];
|
|
118
|
+
for (const pr of prs) {
|
|
119
|
+
if (pr.state === 'merged')
|
|
120
|
+
merged.push(pr);
|
|
121
|
+
else
|
|
122
|
+
unmerged.push(pr);
|
|
123
|
+
}
|
|
124
|
+
return { merged, unmerged };
|
|
125
|
+
}
|
|
126
|
+
function formatPrLine(pr) {
|
|
127
|
+
const state = (pr.state ?? 'unknown').padEnd(7);
|
|
128
|
+
const title = pr.title ? (0, sanitize_1.sanitizeField)(pr.title) : '(untitled)';
|
|
129
|
+
const url = pr.url ? ` ${pr.url}` : '';
|
|
130
|
+
return `#${pr.number} ${state} ${title}${url}`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The changes[] block of the DONE envelope: what is about to change, every
|
|
134
|
+
* linked PR with its synced state, and — last — a ⚠ line naming the PRs that
|
|
135
|
+
* have not merged. That single line replaces LUM-731's second y/N prompt:
|
|
136
|
+
* the protocol is one step, and the agent relays the whole block verbatim.
|
|
137
|
+
*/
|
|
138
|
+
function describeDoneTransition(task, prs) {
|
|
139
|
+
const escapedTitle = (0, sanitize_1.sanitizeField)(task.title).replace(/"/g, '\\"');
|
|
140
|
+
const lines = [
|
|
141
|
+
`Will move ${task.identifier} "${escapedTitle}" to DONE`,
|
|
142
|
+
`Status: ${task.status} → DONE`,
|
|
143
|
+
];
|
|
144
|
+
if (prs.length === 0) {
|
|
145
|
+
lines.push('Pull requests: none linked');
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
for (const pr of prs)
|
|
149
|
+
lines.push(formatPrLine(pr));
|
|
150
|
+
const { unmerged } = partitionLinkedPrs(prs);
|
|
151
|
+
if (unmerged.length > 0) {
|
|
152
|
+
const summary = unmerged
|
|
153
|
+
.map(pr => `#${pr.number} ${pr.state ?? 'unknown'}`)
|
|
154
|
+
.join(', ');
|
|
155
|
+
lines.push(`⚠ ${unmerged.length} linked pull request${unmerged.length === 1 ? '' : 's'} not merged: ${summary}`);
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Look the task up and emit the DONE confirmation envelope (exit 4). Runs
|
|
161
|
+
* before any side effect — tag find-or-create included — so a refused DONE
|
|
162
|
+
* leaves nothing behind. Returns the exit code to propagate; prints its own
|
|
163
|
+
* error on a failed lookup.
|
|
164
|
+
*/
|
|
165
|
+
async function emitDoneConfirmation(args) {
|
|
166
|
+
const { base, apiUrl, token, identifier } = args;
|
|
167
|
+
const showUrl = `${base}/api/tasks/by-identifier/${encodeURIComponent(identifier)}`;
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetch(showUrl, {
|
|
171
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
176
|
+
console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
if (res.status === 401) {
|
|
180
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
let serverMsg = null;
|
|
185
|
+
try {
|
|
186
|
+
const errBody = (await res.json());
|
|
187
|
+
if (typeof errBody.error === 'string')
|
|
188
|
+
serverMsg = errBody.error;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// not JSON
|
|
192
|
+
}
|
|
193
|
+
console.error(serverMsg
|
|
194
|
+
? `Error: ${(0, sanitize_1.sanitizeField)(serverMsg)}`
|
|
195
|
+
: `Error: task lookup failed (HTTP ${res.status})`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
const { task } = (await res.json());
|
|
199
|
+
return (0, confirmation_1.emitConfirmation)({
|
|
200
|
+
command: 'task update',
|
|
201
|
+
changes: describeDoneTransition(task, task.pullRequests ?? []),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
105
204
|
/**
|
|
106
205
|
* Thin wrapper so the next-step block always lands last (LUM-686). The update
|
|
107
206
|
* flow has several success exits — a plain PATCH, plus the sprint bind/unbind
|
|
@@ -181,6 +280,16 @@ async function runTaskUpdate(identifier, opts, collected) {
|
|
|
181
280
|
return 1;
|
|
182
281
|
}
|
|
183
282
|
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
283
|
+
// LUM-755: DONE without --confirm stops here with the confirmation
|
|
284
|
+
// envelope — before tag resolution (find-or-create) or any other write.
|
|
285
|
+
if (status === 'DONE' && !opts.confirm) {
|
|
286
|
+
return emitDoneConfirmation({
|
|
287
|
+
base: (0, api_1.trimTrailingSlash)(apiUrl),
|
|
288
|
+
apiUrl,
|
|
289
|
+
token: creds.token,
|
|
290
|
+
identifier,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
184
293
|
// Resolve tag refs into ids
|
|
185
294
|
let tagIds;
|
|
186
295
|
let addTagIds;
|
|
@@ -276,6 +385,26 @@ async function runTaskUpdate(identifier, opts, collected) {
|
|
|
276
385
|
catch {
|
|
277
386
|
// Body wasn't JSON; fall through to status-only message
|
|
278
387
|
}
|
|
388
|
+
// LUM-755: a DONE refused by a server gate (send-back / boundary
|
|
389
|
+
// crossing / blocking security finding) is a human-only block, not a
|
|
390
|
+
// generic error — surface it structured (exit 5) with every blocker.
|
|
391
|
+
if (res.status === 409 &&
|
|
392
|
+
status === 'DONE' &&
|
|
393
|
+
(0, blocked_error_1.isDoneGateRefusal)(serverMsg)) {
|
|
394
|
+
const { blockers, unconfirmed } = await (0, blocked_error_1.collectDoneBlockers)({
|
|
395
|
+
base,
|
|
396
|
+
token: creds.token,
|
|
397
|
+
identifier,
|
|
398
|
+
});
|
|
399
|
+
return (0, blocked_error_1.emitBlocked)((0, blocked_error_1.buildBlockedError)({
|
|
400
|
+
identifier,
|
|
401
|
+
message: serverMsg,
|
|
402
|
+
blockers,
|
|
403
|
+
unconfirmed,
|
|
404
|
+
apiUrl,
|
|
405
|
+
workspaceSlug: creds.workspaceSlug ?? '',
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
279
408
|
if (serverMsg) {
|
|
280
409
|
console.error(`Error: ${(0, sanitize_1.sanitizeField)(serverMsg)}`);
|
|
281
410
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkpointerEnv = checkpointerEnv;
|
|
3
4
|
exports.runCheckpointer = runCheckpointer;
|
|
4
5
|
exports.verify = verify;
|
|
5
6
|
const child_process_1 = require("child_process");
|
|
@@ -13,6 +14,20 @@ const MAX_OUTPUT_BUFFER = 10 * 1024 * 1024;
|
|
|
13
14
|
function tail(s, max) {
|
|
14
15
|
return s.length > max ? `…${s.slice(-max)}` : s;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The environment a checkpointer runs in: the CLI's own, minus the flags the
|
|
19
|
+
* CLI folds into process.env for itself. `--no-hints` becomes
|
|
20
|
+
* `LUMO_NO_HINTS=1` (see index.ts preAction) and would otherwise be inherited
|
|
21
|
+
* by e.g. a jest suite that covers the hints block, failing it for a reason
|
|
22
|
+
* unrelated to the criterion (LUM-752).
|
|
23
|
+
*/
|
|
24
|
+
const CLI_ONLY_ENV = ['LUMO_NO_HINTS'];
|
|
25
|
+
function checkpointerEnv(base = process.env) {
|
|
26
|
+
const env = { ...base };
|
|
27
|
+
for (const k of CLI_ONLY_ENV)
|
|
28
|
+
delete env[k];
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
16
31
|
/**
|
|
17
32
|
* Execute one MACHINE checkpointer in the local repo (runs client-side — the
|
|
18
33
|
* server can't run repo tests) and fold the result into a structured verdict.
|
|
@@ -26,6 +41,7 @@ function runCheckpointer(criterionId, checkpointer, timeoutMs) {
|
|
|
26
41
|
timeout: timeoutMs,
|
|
27
42
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
28
43
|
cwd: process.cwd(),
|
|
44
|
+
env: checkpointerEnv(),
|
|
29
45
|
});
|
|
30
46
|
if (r.error) {
|
|
31
47
|
const timedOut = r.error.code === 'ETIMEDOUT' ||
|
|
@@ -144,19 +160,12 @@ async function verify(identifier, options = {}) {
|
|
|
144
160
|
`The contract is HUMAN-only; finish your work and hand off for human review (lumo task update ${taskId} --status in_review).\n`);
|
|
145
161
|
return;
|
|
146
162
|
}
|
|
147
|
-
// ──
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
const note = options.note?.trim();
|
|
154
|
-
if (!note) {
|
|
155
|
-
console.error('Error: --note is required — state what you did and why it is ready, e.g.\n' +
|
|
156
|
-
` lumo verify ${taskId} --note "implemented X in foo.ts because Y; tests + tsc pass"\n` +
|
|
157
|
-
'This self-report is recorded as your claim and checked against the diff for faithfulness.');
|
|
158
|
-
return 1;
|
|
159
|
-
}
|
|
163
|
+
// ── Optional self-report (LUM-733) ───────────────────────────────────────
|
|
164
|
+
// `--note` is the agent's one-line self-report. When given it rides with the
|
|
165
|
+
// round and is frozen as the task's claim (source AGENT) on IN_REVIEW; when
|
|
166
|
+
// omitted the round still posts and the claim degrades to the synthesized
|
|
167
|
+
// run summary. It is never a precondition for verifying.
|
|
168
|
+
const note = options.note?.trim() || undefined;
|
|
160
169
|
// ── Execute every checkpointer locally ───────────────────────────────────
|
|
161
170
|
process.stdout.write(`Verifying ${taskId} — ${machine.length} MACHINE criteria\n`);
|
|
162
171
|
const results = [];
|