@lumoai/cli 1.66.0 → 1.69.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 +10 -3
- package/assets/skill/references/confirmation.md +3 -2
- package/assets/skill/references/criteria.md +107 -0
- package/assets/skill/references/memory.md +29 -0
- package/assets/skill/references/pr-scan.md +129 -0
- package/assets/skill/references/verify.md +3 -3
- package/dist/cli/src/commands/memory-rm.js +9 -1
- package/dist/cli/src/commands/pr-scan.js +224 -0
- package/dist/cli/src/commands/task-criteria-confirm.js +99 -0
- package/dist/cli/src/commands/task-status.js +84 -89
- package/dist/cli/src/commands/verify.js +4 -1
- package/dist/cli/src/index.js +18 -0
- package/dist/cli/src/lib/scan-format.js +84 -0
- package/dist/lib/acceptance/unmet-display.js +28 -0
- package/dist/shared/src/hunt-audit.js +83 -0
- package/dist/shared/src/untrusted-content.js +284 -52
- package/package.json +1 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatPrScan = formatPrScan;
|
|
4
|
+
exports.prScan = prScan;
|
|
5
|
+
const config_1 = require("../lib/config");
|
|
6
|
+
const api_1 = require("../lib/api");
|
|
7
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
8
|
+
const redaction_notice_1 = require("../lib/redaction-notice");
|
|
9
|
+
const scan_format_1 = require("../lib/scan-format");
|
|
10
|
+
const security_scan_1 = require("../../../shared/src/security-scan");
|
|
11
|
+
const hunt_audit_1 = require("../../../shared/src/hunt-audit");
|
|
12
|
+
/** How many hunt tasks `--full` lists per scan — a plan holds at most 5. */
|
|
13
|
+
const FULL_TASK_CAP = 8;
|
|
14
|
+
/**
|
|
15
|
+
* `(blocks DONE)` / `(advisory)` for an open row; the disposition and where
|
|
16
|
+
* it came from for a ruled row; the LUM-775 exit or `already on main`
|
|
17
|
+
* otherwise. One tag per row — the row's standing is one fact.
|
|
18
|
+
*/
|
|
19
|
+
function findingStanding(f) {
|
|
20
|
+
if (f.disposition) {
|
|
21
|
+
const source = f.dispositionSource === 'INHERITED'
|
|
22
|
+
? 'inherited from an earlier scan of this PR'
|
|
23
|
+
: f.dispositionSource === 'SUGGESTED_CONFIRMED'
|
|
24
|
+
? 'AI-suggested, human-confirmed'
|
|
25
|
+
: 'human';
|
|
26
|
+
const note = f.dispositionNote
|
|
27
|
+
? ` · "${(0, sanitize_1.sanitizeField)(f.dispositionNote)}"`
|
|
28
|
+
: '';
|
|
29
|
+
return `${f.disposition} · ${source}${note}`;
|
|
30
|
+
}
|
|
31
|
+
if (f.persisting)
|
|
32
|
+
return 'already on main';
|
|
33
|
+
if (f.queueExit) {
|
|
34
|
+
return security_scan_1.QUEUE_EXIT_LABELS[f.queueExit] ?? f.queueExit;
|
|
35
|
+
}
|
|
36
|
+
return f.blocking ? 'blocks DONE' : 'advisory';
|
|
37
|
+
}
|
|
38
|
+
/** Same line grammar as the `[SECURITY]` next-action rows of `lumo task status`. */
|
|
39
|
+
function formatFinding(f) {
|
|
40
|
+
const where = f.line == null ? f.filePath : `${f.filePath}:${f.line}`;
|
|
41
|
+
const note = (0, sanitize_1.sanitizeField)((0, security_scan_1.secretDowngradeNote)(f.secretDowngrade));
|
|
42
|
+
const falsifier = f.falsifierVerdict
|
|
43
|
+
? ` · falsifier ${f.falsifierVerdict}`
|
|
44
|
+
: '';
|
|
45
|
+
const suggestion = f.disposition === null && f.suggestedDisposition
|
|
46
|
+
? ` · AI suggests ${f.suggestedDisposition}`
|
|
47
|
+
: '';
|
|
48
|
+
return `[${f.severity}] ${f.provenance} ${(0, sanitize_1.sanitizeField)(f.ruleId)} — ${(0, sanitize_1.sanitizeField)(where)} — ${(0, sanitize_1.sanitizeField)(f.title)}${note} (${findingStanding(f)}${falsifier}${suggestion})`;
|
|
49
|
+
}
|
|
50
|
+
function plural(n, one, many) {
|
|
51
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
52
|
+
}
|
|
53
|
+
function formatTokens(n) {
|
|
54
|
+
// null = not metered (team rule: null ≠ 0) — say so rather than print 0.
|
|
55
|
+
return n === null
|
|
56
|
+
? 'tokens not metered'
|
|
57
|
+
: `${n.toLocaleString('en-US')} tokens`;
|
|
58
|
+
}
|
|
59
|
+
function formatHuntHeader(h) {
|
|
60
|
+
const parts = [];
|
|
61
|
+
if (h.plannedBy) {
|
|
62
|
+
parts.push(`planned by ${(0, sanitize_1.sanitizeField)(h.plannedBy)}${h.plannedReason ? ` — ${(0, sanitize_1.sanitizeField)(h.plannedReason)}` : ''}`);
|
|
63
|
+
}
|
|
64
|
+
if (h.diff) {
|
|
65
|
+
parts.push(`diff ${plural(h.diff.sourceFiles, 'source file', 'source files')} · ${plural(h.diff.sites, 'site', 'sites')} · task cap ${h.diff.taskCap}${h.diff.oversized ? ' · oversized (a corner of the diff was covered)' : ''}`);
|
|
66
|
+
}
|
|
67
|
+
if (h.candidates !== null)
|
|
68
|
+
parts.push(`candidates ${h.candidates}`);
|
|
69
|
+
if (h.mechanicallyRejected !== null)
|
|
70
|
+
parts.push(`mechanically rejected ${h.mechanicallyRejected}`);
|
|
71
|
+
if (h.falsifierRejects !== null)
|
|
72
|
+
parts.push(`falsifier rejects ${h.falsifierRejects}`);
|
|
73
|
+
if (h.error)
|
|
74
|
+
parts.push(`error: ${(0, sanitize_1.sanitizeField)(h.error)}`);
|
|
75
|
+
return parts.length === 0
|
|
76
|
+
? 'Hunt: (no audit recorded)'
|
|
77
|
+
: `Hunt: ${parts.join(' · ')}`;
|
|
78
|
+
}
|
|
79
|
+
function formatScan(s, full) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
const finished = s.finishedAt
|
|
82
|
+
? ` · finished ${s.finishedAt}`
|
|
83
|
+
: ' · not finished';
|
|
84
|
+
lines.push(`Scan ${(0, sanitize_1.sanitizeField)(s.headSha.slice(0, 7))} · ${s.status} · started ${s.startedAt}${finished} · trigger ${(0, sanitize_1.sanitizeField)(s.trigger)} · ${formatTokens(s.tokenSpent)}${s.truncated ? ' · diff truncated for AI review' : ''}`);
|
|
85
|
+
const tail = (0, scan_format_1.formatScanLayerTail)({
|
|
86
|
+
stages: s.stages,
|
|
87
|
+
partial: s.partial,
|
|
88
|
+
externalFindings: s.counts.externalFindings,
|
|
89
|
+
dependencyFindings: s.counts.dependencyFindings,
|
|
90
|
+
persistingFindings: s.counts.persisting,
|
|
91
|
+
queueExits: s.queueExits,
|
|
92
|
+
externalFailureReason: s.externalFailureReason,
|
|
93
|
+
});
|
|
94
|
+
// The tail starts with ` · `; on its own line the leading separator goes.
|
|
95
|
+
if (tail)
|
|
96
|
+
lines.push(` ${tail.replace(/^ · /, '')}`);
|
|
97
|
+
if (s.hunt?.coverage && s.hunt.coverage.categories.length > 0) {
|
|
98
|
+
lines.push(` ${(0, scan_format_1.formatHuntCoverageLine)(s.hunt.coverage)}`);
|
|
99
|
+
}
|
|
100
|
+
const c = s.counts;
|
|
101
|
+
if (c.findings === 0) {
|
|
102
|
+
lines.push(' Findings: none on this scan');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const breakdown = [
|
|
106
|
+
`${c.open} open`,
|
|
107
|
+
`${c.blocking} block${c.blocking === 1 ? 's' : ''} DONE`,
|
|
108
|
+
`${c.dispositioned} dispositioned`,
|
|
109
|
+
];
|
|
110
|
+
if (c.persisting > 0)
|
|
111
|
+
breakdown.push(`${c.persisting} already on main`);
|
|
112
|
+
lines.push(` Findings: ${c.findings} (${breakdown.join(' · ')})`);
|
|
113
|
+
for (const f of s.findings)
|
|
114
|
+
lines.push(` ${formatFinding(f)}`);
|
|
115
|
+
}
|
|
116
|
+
if (full && s.hunt) {
|
|
117
|
+
lines.push(` ${formatHuntHeader(s.hunt)}`);
|
|
118
|
+
for (const l of (0, hunt_audit_1.formatHuntTaskDetail)(s.hunt.tasks, FULL_TASK_CAP)) {
|
|
119
|
+
lines.push(` ${l}`);
|
|
120
|
+
}
|
|
121
|
+
if (s.hunt.tasks.length === 0)
|
|
122
|
+
lines.push(' (no per-task audit rows)');
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Render the read-out as prose. The PR header first (repo, state, linked
|
|
128
|
+
* task or none), then one block per scan, newest first. A PR with no scan
|
|
129
|
+
* says so in the fail-closed wording of `lumo task status` — never an
|
|
130
|
+
* implicit "clean".
|
|
131
|
+
*/
|
|
132
|
+
function formatPrScan(data, opts = {}) {
|
|
133
|
+
const pr = data.pullRequest;
|
|
134
|
+
const lines = [];
|
|
135
|
+
lines.push(`PR #${pr.number} · ${(0, sanitize_1.sanitizeField)(pr.repo)} · ${(0, sanitize_1.sanitizeField)(pr.state)} · ${pr.task ? `task ${(0, sanitize_1.sanitizeField)(pr.task.identifier)}` : 'no linked task'}`);
|
|
136
|
+
lines.push((0, sanitize_1.sanitizeField)(pr.title));
|
|
137
|
+
lines.push((0, sanitize_1.sanitizeField)(pr.url));
|
|
138
|
+
if (data.scans.length === 0) {
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push(`⚠ No security scan has run for PR #${pr.number} — could not confirm it is clean.`);
|
|
141
|
+
return lines.join('\n') + '\n';
|
|
142
|
+
}
|
|
143
|
+
if (data.all) {
|
|
144
|
+
lines.push('');
|
|
145
|
+
lines.push(`${plural(data.scans.length, 'scan', 'scans')} on record, newest first`);
|
|
146
|
+
}
|
|
147
|
+
for (const s of data.scans) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(...formatScan(s, opts.full === true));
|
|
150
|
+
}
|
|
151
|
+
return lines.join('\n') + '\n';
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* `lumo pr scan <number> [--repo owner/repo] [--all] [--json] [--full]`
|
|
155
|
+
*
|
|
156
|
+
* The security scans of one PR in the workspace, by PR number — linked to a
|
|
157
|
+
* task or not. Reads `GET /api/pull-requests/:number/scans`; read-only.
|
|
158
|
+
* Disposition stays web-only (LUM-750's CLI path was rolled back).
|
|
159
|
+
*/
|
|
160
|
+
async function prScan(numberArg, opts = {}) {
|
|
161
|
+
if (!numberArg || !/^\d+$/.test(numberArg.trim())) {
|
|
162
|
+
console.error('Error: usage: lumo pr scan <number> [--repo owner/repo] [--all] [--json] [--full]');
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
if (opts.repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(opts.repo)) {
|
|
166
|
+
console.error(`Error: --repo must be owner/repo, got "${opts.repo}"`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const creds = (0, config_1.readCredentials)();
|
|
170
|
+
if (!creds) {
|
|
171
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
175
|
+
const base = (0, api_1.trimTrailingSlash)(apiUrl);
|
|
176
|
+
const query = new URLSearchParams();
|
|
177
|
+
if (opts.repo)
|
|
178
|
+
query.set('repo', opts.repo);
|
|
179
|
+
if (opts.all)
|
|
180
|
+
query.set('all', '1');
|
|
181
|
+
const qs = query.toString();
|
|
182
|
+
const url = `${base}/api/pull-requests/${encodeURIComponent(numberArg.trim())}/scans${qs ? `?${qs}` : ''}`;
|
|
183
|
+
let res;
|
|
184
|
+
try {
|
|
185
|
+
res = await fetch(url, {
|
|
186
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
if (res.status === 401) {
|
|
195
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
if (res.status === 404) {
|
|
199
|
+
console.error(`Error: PR #${numberArg}${opts.repo ? ` in ${opts.repo}` : ''} not found in workspace ${creds.workspaceSlug}`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
if (res.status === 409) {
|
|
203
|
+
const body = (await res.json().catch(() => ({})));
|
|
204
|
+
console.error(`Error: ${(0, sanitize_1.sanitizeField)(body.error ?? `PR #${numberArg} exists in several repos`)}`);
|
|
205
|
+
for (const r of body.repos ?? []) {
|
|
206
|
+
console.error(` lumo pr scan ${numberArg} --repo ${(0, sanitize_1.sanitizeField)(r)}`);
|
|
207
|
+
}
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
const body = (await res.json().catch(() => ({})));
|
|
212
|
+
console.error(`Error: pr scan failed (HTTP ${res.status})${body.error ? `: ${(0, sanitize_1.sanitizeField)(body.error)}` : ''}`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
const data = (await res.json());
|
|
216
|
+
if (opts.json) {
|
|
217
|
+
// JSON.stringify escapes control characters, so the payload is safe to
|
|
218
|
+
// print as-is; the version field is the consumer's contract.
|
|
219
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
(0, redaction_notice_1.printRedactionNotice)(data.redactedSecrets);
|
|
223
|
+
process.stdout.write(formatPrScan(data, { full: opts.full }));
|
|
224
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.taskCriteriaConfirm = taskCriteriaConfirm;
|
|
4
|
+
const config_1 = require("../lib/config");
|
|
5
|
+
const api_1 = require("../lib/api");
|
|
6
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
7
|
+
const confirmation_1 = require("../lib/confirmation");
|
|
8
|
+
/**
|
|
9
|
+
* `lumo task criteria confirm <task> [--confirm]`
|
|
10
|
+
*
|
|
11
|
+
* 记录人对验收合同的一次性确认(LUM-789)。这是 gated mutation:不带
|
|
12
|
+
* `--confirm` 时打确认信封并 exit 4,**写入为零**;agent 把 changes 逐条念给
|
|
13
|
+
* 用户,用户批准后才跑信封里的 confirmCommand。`--confirm` 的含义是「用户已
|
|
14
|
+
* 确认」,agent 不得自行添加。
|
|
15
|
+
*/
|
|
16
|
+
async function taskCriteriaConfirm(identifier, options) {
|
|
17
|
+
if (!identifier) {
|
|
18
|
+
console.error('Error: missing <task>. Usage: lumo task criteria confirm <LUM-42>');
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
const creds = (0, config_1.readCredentials)();
|
|
22
|
+
if (!creds) {
|
|
23
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
|
|
27
|
+
const headers = { Authorization: `Bearer ${creds.token}` };
|
|
28
|
+
let listRes;
|
|
29
|
+
try {
|
|
30
|
+
listRes = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria`, { headers });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
+
console.error(`Error: could not reach Lumo API at ${base} (${msg})`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (listRes.status === 401) {
|
|
38
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
if (listRes.status === 404) {
|
|
42
|
+
console.error(`Error: task ${identifier} not found in workspace ${creds.workspaceSlug}`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
if (!listRes.ok) {
|
|
46
|
+
console.error(`Error: could not read the contract (HTTP ${listRes.status})`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const { criteria, status } = (await listRes.json());
|
|
50
|
+
if (criteria.length === 0) {
|
|
51
|
+
console.error(`Error: ${identifier} has no acceptance criteria — nothing to confirm.`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
// N-5: pre-check DONE before printing the exit-4 envelope — without this,
|
|
55
|
+
// the envelope promises a write ("Will record YOUR confirmation…") that
|
|
56
|
+
// `confirmContract`'s own DONE guard (Finding 5, first review) always
|
|
57
|
+
// refuses with 409, so a DONE task only ever discovered that on the
|
|
58
|
+
// SECOND call, after the user had already approved a write that was never
|
|
59
|
+
// going to happen. Same terminal-lock framing as `criteria set --human`'s
|
|
60
|
+
// DONE 409 and the server's own message.
|
|
61
|
+
if (status === 'DONE') {
|
|
62
|
+
console.error(`Error: ${identifier} is DONE — the acceptance contract is locked. Reopen it (status → in_progress) to confirm the contract.`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
if (!(0, confirmation_1.isConfirmed)(options)) {
|
|
66
|
+
const pending = criteria.filter(c => c.confirmedAt == null);
|
|
67
|
+
return (0, confirmation_1.emitConfirmation)({
|
|
68
|
+
command: 'task criteria confirm',
|
|
69
|
+
changes: [
|
|
70
|
+
`Will record YOUR confirmation of the acceptance contract on ${identifier}`,
|
|
71
|
+
`${pending.length} of ${criteria.length} criteria are not yet confirmed`,
|
|
72
|
+
...criteria.map(c => ` [${c.verifierType}] ${(0, sanitize_1.sanitizeField)(c.statement)}${c.confirmedAt ? ' (already confirmed)' : ''}`),
|
|
73
|
+
'Confirming does NOT close the task and never blocks DONE — it records that a human agreed to what "done" means here',
|
|
74
|
+
'To change the contract instead, run `lumo task criteria set <task> --file <criteria.json> --human`',
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria/confirm`, { method: 'POST', headers });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
console.error(`Error: could not reach Lumo API at ${base} (${msg})`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (res.status === 401) {
|
|
88
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (res.status !== 201) {
|
|
92
|
+
const body = (await res.json().catch(() => null));
|
|
93
|
+
const detail = typeof body?.error === 'string' ? (0, sanitize_1.sanitizeField)(body.error) : '';
|
|
94
|
+
console.error(`Error: confirm failed (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const { confirmedCount } = (await res.json());
|
|
98
|
+
process.stdout.write(`Confirmed the acceptance contract on ${identifier} (${confirmedCount} criteri${confirmedCount === 1 ? 'on' : 'a'} newly confirmed)\n`);
|
|
99
|
+
}
|
|
@@ -7,8 +7,16 @@ 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
9
|
const security_scan_1 = require("../../../shared/src/security-scan");
|
|
10
|
+
const scan_format_1 = require("../lib/scan-format");
|
|
10
11
|
const open_crossings_1 = require("../lib/open-crossings");
|
|
11
12
|
const evidence_display_1 = require("../lib/evidence-display");
|
|
13
|
+
// LUM-789 whole-branch review (Findings 1/2): the CLI must not run a second,
|
|
14
|
+
// disagreeing "is this criterion unmet" rule against the server's nextActions
|
|
15
|
+
// split — both derive from the same isUnmetForDisplay so a tacit-pass HUMAN
|
|
16
|
+
// row reads as settled everywhere in one report, never "0 unmet" in the
|
|
17
|
+
// header and an open circle in the body. Zero-import — safe to pull into the
|
|
18
|
+
// CLI package the same way client components pull it into the web bundle.
|
|
19
|
+
const unmet_display_1 = require("../../../lib/acceptance/unmet-display");
|
|
12
20
|
/** One-line a possibly-multiline crossing detail and cap it so the safety block
|
|
13
21
|
* stays a glance, never a wall (it must not overshadow the criteria). */
|
|
14
22
|
const CROSSING_DETAIL_CAP = 160;
|
|
@@ -58,89 +66,23 @@ function pushSecurityActions(lines, securityActions) {
|
|
|
58
66
|
lines.push(` • [SECURITY] ${formatSecurityAction(a)}`);
|
|
59
67
|
}
|
|
60
68
|
}
|
|
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
69
|
/** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
|
|
66
|
-
* Secrets: checked · Code scan: checked (2 external findings) ·
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* Stage segments only for keys present, in
|
|
72
|
-
* secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
|
|
73
|
-
* layer); the `(N external findings)` count only decorates the Code scan
|
|
74
|
-
* segment, the dependency count (LUM-738, with its already-on-main share)
|
|
75
|
-
* only the Dependencies segment, and each only when N > 0.
|
|
76
|
-
* LUM-763: the stage keys are internal — what prints is the display label
|
|
77
|
-
* from `shared/src/security-scan.ts`, the same map the GitHub PR summary
|
|
78
|
-
* and the web panel render from. */
|
|
70
|
+
* Secrets: checked · Code scan: checked (2 external findings) · …` — the
|
|
71
|
+
* layer grammar lives in `cli/src/lib/scan-format.ts`, shared with
|
|
72
|
+
* `lumo pr scan` (LUM-788) so the two surfaces cannot drift.
|
|
73
|
+
* LUM-756 (P8): `error` has two writers — only the prefixed, scrubbed
|
|
74
|
+
* scanner reason may be printed, and only when the layer FAILED. */
|
|
79
75
|
function formatScanSummaryLine(s) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
|
|
91
|
-
const n = s.dependencyFindings ?? 0;
|
|
92
|
-
const noun = n === 1 ? 'finding' : 'findings';
|
|
93
|
-
const persisting = s.persistingFindings ?? 0;
|
|
94
|
-
const tail = persisting > 0 ? `, ${persisting} already on main` : '';
|
|
95
|
-
segments.push(`${head} (${n} dependency ${noun}${tail})`);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
segments.push(head);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
let line = ` PR #${s.prNumber} · scan ${s.status}`;
|
|
102
|
-
if (segments.length > 0)
|
|
103
|
-
line += ` · ${segments.join(' · ')}`;
|
|
104
|
-
if (s.partial)
|
|
105
|
-
line += ' · partial';
|
|
106
|
-
// LUM-775: what left the queue because the PR merged / closed, and why —
|
|
107
|
-
// so a merged PR's line explains where its findings went instead of
|
|
108
|
-
// silently listing fewer next actions than the scan found.
|
|
109
|
-
const exits = (0, security_scan_1.formatQueueExits)(s.queueExits);
|
|
110
|
-
if (exits)
|
|
111
|
-
line += ` · ${exits}`;
|
|
112
|
-
// LUM-756 (P8): `error` has two writers — only the prefixed, scrubbed
|
|
113
|
-
// scanner reason may be printed, and only the text after the prefix. Stage
|
|
114
|
-
// A's raw crash text carries no prefix and is never shown here (same rule
|
|
115
|
-
// as the web panel and the PR summary).
|
|
116
|
-
const reason = s.stages.external === 'FAILED' ? (0, security_scan_1.externalFailureReason)(s.error) : null;
|
|
117
|
-
if (reason) {
|
|
118
|
-
line += ` — ${(0, sanitize_1.sanitizeField)(tail(reason, EXTERNAL_ERROR_TAIL))}`;
|
|
119
|
-
}
|
|
120
|
-
return line;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* LUM-762 — the hunt's per-task read-out, one indented line under its scan:
|
|
124
|
-
* `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
|
|
125
|
-
* (2 attempts) · t3 race skipped`. Headed by the same display label as the
|
|
126
|
-
* layer's own segment (LUM-763) — the detail line and the line above it
|
|
127
|
-
* must not name the layer two different ways. This is the only place the stored `huntAudit` surfaces, so
|
|
128
|
-
* it is what answers "how long does a healthy batch take" and "which ones
|
|
129
|
-
* never ran" (`skipped`) without paging through deploy logs. Elapsed time is
|
|
130
|
-
* summed over a task's attempts; a skipped task has none to print.
|
|
131
|
-
*
|
|
132
|
-
* LUM-758: a plan may hold two tasks of the same category, so the row is led
|
|
133
|
-
* by its task id. Pre-task-graph audits carry no id — the row then prints
|
|
134
|
-
* exactly as it always did.
|
|
135
|
-
*/
|
|
136
|
-
function formatHuntCoverageLine(hunt) {
|
|
137
|
-
const parts = hunt.categories.map(c => {
|
|
138
|
-
const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
|
|
139
|
-
const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
|
|
140
|
-
const id = c.taskId === undefined ? '' : `${c.taskId} `;
|
|
141
|
-
return `${id}${c.category} ${spent}${c.stopped}${attempts}`;
|
|
142
|
-
});
|
|
143
|
-
return ` ${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
|
|
76
|
+
return (` PR #${s.prNumber} · scan ${s.status}` +
|
|
77
|
+
(0, scan_format_1.formatScanLayerTail)({
|
|
78
|
+
stages: s.stages,
|
|
79
|
+
partial: s.partial,
|
|
80
|
+
externalFindings: s.externalFindings,
|
|
81
|
+
dependencyFindings: s.dependencyFindings,
|
|
82
|
+
persistingFindings: s.persistingFindings,
|
|
83
|
+
queueExits: s.queueExits,
|
|
84
|
+
externalFailureReason: s.stages.external === 'FAILED' ? (0, security_scan_1.externalFailureReason)(s.error) : null,
|
|
85
|
+
}));
|
|
144
86
|
}
|
|
145
87
|
function pushSecurityCoverage(lines, data) {
|
|
146
88
|
if (data.securityFindings === undefined)
|
|
@@ -154,7 +96,7 @@ function pushSecurityCoverage(lines, data) {
|
|
|
154
96
|
lines.push(formatScanSummaryLine(s));
|
|
155
97
|
// LUM-762: absent/null = older server or no usable audit — stay silent.
|
|
156
98
|
if (s.hunt && s.hunt.categories.length > 0) {
|
|
157
|
-
lines.push(formatHuntCoverageLine(s.hunt));
|
|
99
|
+
lines.push(` ${(0, scan_format_1.formatHuntCoverageLine)(s.hunt)}`);
|
|
158
100
|
}
|
|
159
101
|
}
|
|
160
102
|
for (const u of data.securityFindings.unconfirmedPrs) {
|
|
@@ -166,11 +108,34 @@ function pushSecurityCoverage(lines, data) {
|
|
|
166
108
|
lines.push(`⚠ PR #${u.number}: ${why} — could not confirm it is clean.`);
|
|
167
109
|
}
|
|
168
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Finding 2: the closing line for a fully-settled contract (no unmet
|
|
113
|
+
* criteria, no open security findings). Mirrors the web's three-way split
|
|
114
|
+
* (AcceptanceTab.tsx's humanNoneJudged / humanSomeJudged /
|
|
115
|
+
* humanAllExplicitlyJudged, from commit 749bdbf2) instead of unconditionally
|
|
116
|
+
* claiming "awaiting human adjudication" — which is false the moment even one
|
|
117
|
+
* HUMAN criterion settled as a tacit pass rather than an actual verdict.
|
|
118
|
+
*/
|
|
119
|
+
function contractSettledLine(criteria) {
|
|
120
|
+
const humanCriteria = criteria.filter(c => c.verifierType === 'HUMAN');
|
|
121
|
+
if (humanCriteria.length === 0) {
|
|
122
|
+
return 'All criteria met by their latest verdicts.';
|
|
123
|
+
}
|
|
124
|
+
const judgedCount = humanCriteria.filter(c => c.latestVerdict != null).length;
|
|
125
|
+
if (judgedCount === humanCriteria.length) {
|
|
126
|
+
return 'All criteria met by their latest verdicts — human-judged.';
|
|
127
|
+
}
|
|
128
|
+
if (judgedCount === 0) {
|
|
129
|
+
return 'All criteria met — no objection raised on the HUMAN criteria; not individually judged.';
|
|
130
|
+
}
|
|
131
|
+
return `All criteria met — ${judgedCount}/${humanCriteria.length} HUMAN criteria explicitly judged, the rest accepted as drafted.`;
|
|
132
|
+
}
|
|
169
133
|
/**
|
|
170
134
|
* Render the acceptance status as prose. Same row grammar as
|
|
171
135
|
* `criteria list` (`<id> [TYPE] SOURCE@rN statement`) with a verdict
|
|
172
|
-
* glyph in front — ✓ pass, ✗ fail, ○ no verdict yet
|
|
173
|
-
*
|
|
136
|
+
* glyph in front — ✓ pass, ✗ fail, ○ no verdict yet, ~ tacit pass (settled,
|
|
137
|
+
* un-adjudicated HUMAN criterion) — so REVIEW_ADDED provenance stays
|
|
138
|
+
* explicitly visible in every row.
|
|
174
139
|
*/
|
|
175
140
|
function formatTaskStatus(data, extras = {}) {
|
|
176
141
|
const lines = [];
|
|
@@ -180,6 +145,16 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
180
145
|
? 'no verification rounds yet'
|
|
181
146
|
: `verification round ${data.currentRound}/${data.maxRounds}`;
|
|
182
147
|
lines.push(`Status: ${t.status} · ${roundLabel}`);
|
|
148
|
+
// LUM-789: 追认入口。出现在用户本来就要打开这个任务的时刻,永不拦 DONE。
|
|
149
|
+
// Finding 5: the migration is backfill-free, so every pre-LUM-789 contract
|
|
150
|
+
// reads unconfirmed forever — without the DONE guard this nudge would sit
|
|
151
|
+
// on every historical and every closed task forever, inviting exactly the
|
|
152
|
+
// after-delivery "confirm" that defeats the point of moving the touchpoint
|
|
153
|
+
// earlier. Mirrors AcceptanceTab.tsx's `contractUnconfirmed` guard.
|
|
154
|
+
const unconfirmed = t.status === 'DONE' ? [] : data.criteria.filter(c => c.confirmedAt == null);
|
|
155
|
+
if (unconfirmed.length > 0) {
|
|
156
|
+
lines.push(`Contract unconfirmed (${unconfirmed.length} criteri${unconfirmed.length === 1 ? 'on' : 'a'}) — lumo task criteria confirm ${t.identifier}`);
|
|
157
|
+
}
|
|
183
158
|
if (data.escalated) {
|
|
184
159
|
lines.push('⚠ Escalated: the machine loop is exhausted — a human has been paged. Stop retrying lumo verify.');
|
|
185
160
|
}
|
|
@@ -213,8 +188,17 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
213
188
|
lines.push('');
|
|
214
189
|
lines.push(`Criteria (${data.criteria.length} total, ${unmetCriteria.length} unmet):`);
|
|
215
190
|
for (const c of data.criteria) {
|
|
191
|
+
// Finding 1: derive from the same isUnmetForDisplay the header's
|
|
192
|
+
// `unmet` count and the server's nextActions already use. A HUMAN
|
|
193
|
+
// criterion with no verdict is a tacit pass (settled, nobody objected) —
|
|
194
|
+
// it must not render as an open circle awaiting something, but it also
|
|
195
|
+
// didn't get an actual verdict, so it stays visually distinct from a
|
|
196
|
+
// real ✓ pass (LUM-789's "keep the judged-vs-tacit distinction honest").
|
|
197
|
+
const unmet = (0, unmet_display_1.isUnmetForDisplay)(c, c.latestVerdict ?? undefined);
|
|
216
198
|
const glyph = c.latestVerdict == null
|
|
217
|
-
?
|
|
199
|
+
? unmet
|
|
200
|
+
? '○' // MACHINE, no verdict — genuinely still open
|
|
201
|
+
: '~' // HUMAN, no verdict — tacit pass, settled but unjudged
|
|
218
202
|
: c.latestVerdict.verdict === 'FAIL'
|
|
219
203
|
? '✗'
|
|
220
204
|
: '✓';
|
|
@@ -235,7 +219,9 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
235
219
|
}
|
|
236
220
|
const v = c.latestVerdict;
|
|
237
221
|
if (v == null) {
|
|
238
|
-
lines.push(
|
|
222
|
+
lines.push(unmet
|
|
223
|
+
? ' (no verdict yet)'
|
|
224
|
+
: ' (tacit pass — no objection raised, not individually judged)');
|
|
239
225
|
}
|
|
240
226
|
else if (v.verdict === 'FAIL') {
|
|
241
227
|
const why = v.rejectionReason
|
|
@@ -314,7 +300,7 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
314
300
|
: ` · ${securityActions.length} security finding${securityActions.length === 1 ? '' : 's'}`;
|
|
315
301
|
if (unmetCriteria.length === 0 && securityActions.length === 0) {
|
|
316
302
|
lines.push(data.currentRound > 0
|
|
317
|
-
?
|
|
303
|
+
? contractSettledLine(data.criteria)
|
|
318
304
|
: 'Nothing unmet — but no verification has run; run `lumo verify` to judge the contract.');
|
|
319
305
|
}
|
|
320
306
|
else {
|
|
@@ -338,7 +324,9 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
338
324
|
const left = data.maxRounds - data.currentRound;
|
|
339
325
|
lines.push(hasMachine
|
|
340
326
|
? `Fix the unmet criteria, then run \`lumo verify\` (${left} round${left === 1 ? '' : 's'} left).`
|
|
341
|
-
:
|
|
327
|
+
: // LUM-789: 未裁定的 HUMAN 项已按 tacit pass 处理,不会走到这里。
|
|
328
|
+
// 剩下的 HUMAN 项只可能是被明确送回的 —— 说送回,不说交接。
|
|
329
|
+
'The remaining criteria were sent back by a human — address the reasons above, then run `lumo verify`.');
|
|
342
330
|
}
|
|
343
331
|
if (securityActions.length > 0)
|
|
344
332
|
lines.push(SECURITY_HINT);
|
|
@@ -410,8 +398,15 @@ function pushVerificationRollup(lines, unmetCriteriaCount, data) {
|
|
|
410
398
|
}
|
|
411
399
|
// LUM-737 (whole-branch review): `unmetCriteriaCount` excludes SECURITY_FINDING
|
|
412
400
|
// next actions — met/unmet counts criteria only, never security findings.
|
|
401
|
+
//
|
|
402
|
+
// Relocation six (whole-branch review, third pass): `unmetCriteriaCount` is
|
|
403
|
+
// now isUnmetForDisplay-filtered (Findings 1/2) — a tacit-pass HUMAN
|
|
404
|
+
// criterion (no verdict, never adjudicated) counts as met here too. "met by
|
|
405
|
+
// their latest verdict" asserted a verdict that row doesn't have; dropped
|
|
406
|
+
// the claim rather than the count, so this line agrees with the `~` row
|
|
407
|
+
// and the closing line instead of contradicting them.
|
|
413
408
|
const met = data.criteria.length - unmetCriteriaCount;
|
|
414
|
-
lines.push(` ${met} of ${data.criteria.length} criteria met
|
|
409
|
+
lines.push(` ${met} of ${data.criteria.length} criteria met`);
|
|
415
410
|
}
|
|
416
411
|
/**
|
|
417
412
|
* Append the honest "Cost" section (LUM-560) — 规律 1: surface the costs a human
|
|
@@ -157,7 +157,10 @@ async function verify(identifier, options = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
if (machine.length === 0) {
|
|
159
159
|
process.stdout.write(`${taskId} has no MACHINE criteria — nothing for the machine loop to run.\n` +
|
|
160
|
-
|
|
160
|
+
// LUM-789: 未裁定的 HUMAN 项按 tacit pass 处理,除非被人明确送回,
|
|
161
|
+
// 否则不构成"必须交接给人 review"的义务 —— 别再说 hand off。
|
|
162
|
+
`HUMAN criteria pass tacitly unless one is explicitly sent back by a human; ` +
|
|
163
|
+
`no hand-off is required. Move on with \`lumo task update ${taskId} --status in_review\`.\n`);
|
|
161
164
|
return;
|
|
162
165
|
}
|
|
163
166
|
// ── Optional self-report (LUM-733) ───────────────────────────────────────
|
package/dist/cli/src/index.js
CHANGED
|
@@ -92,6 +92,7 @@ const memory_fold_1 = require("./commands/memory-fold");
|
|
|
92
92
|
const task_artifact_add_1 = require("./commands/task-artifact-add");
|
|
93
93
|
const task_criteria_set_1 = require("./commands/task-criteria-set");
|
|
94
94
|
const task_criteria_list_1 = require("./commands/task-criteria-list");
|
|
95
|
+
const task_criteria_confirm_1 = require("./commands/task-criteria-confirm");
|
|
95
96
|
const task_artifact_list_1 = require("./commands/task-artifact-list");
|
|
96
97
|
const task_artifact_show_1 = require("./commands/task-artifact-show");
|
|
97
98
|
const task_artifact_rm_1 = require("./commands/task-artifact-rm");
|
|
@@ -102,6 +103,7 @@ const task_figma_context_1 = require("./commands/task-figma-context");
|
|
|
102
103
|
const task_comment_list_1 = require("./commands/task-comment-list");
|
|
103
104
|
const task_deps_1 = require("./commands/task-deps");
|
|
104
105
|
const task_pr_show_1 = require("./commands/task-pr-show");
|
|
106
|
+
const pr_scan_1 = require("./commands/pr-scan");
|
|
105
107
|
const task_lineage_1 = require("./commands/task-lineage");
|
|
106
108
|
const project_list_1 = require("./commands/project-list");
|
|
107
109
|
const milestone_list_1 = require("./commands/milestone-list");
|
|
@@ -434,6 +436,16 @@ priorityCmd
|
|
|
434
436
|
.command('list')
|
|
435
437
|
.description('Print the team priority append history, newest-first (the current declaration is marked with *).')
|
|
436
438
|
.action(wrap(() => (0, priority_1.priorityList)()));
|
|
439
|
+
const pr = program
|
|
440
|
+
.command('pr')
|
|
441
|
+
.description('Inspect pull requests by number — linked to a task or not');
|
|
442
|
+
pr.command('scan <number>')
|
|
443
|
+
.description('Security scans of one PR in the workspace, by PR number (LUM-788): the latest head by default — layer states (same labels as task status / the GitHub summary), every finding incl. dispositioned ones with disposition + source, and the hunt coverage line. Read-only; disposition is web-only.')
|
|
444
|
+
.option('--repo <owner/repo>', 'Narrow to one repo when the same PR number exists in several (the error lists the candidates)')
|
|
445
|
+
.option('--all', "Every pushed head's scan, newest first, instead of the latest only")
|
|
446
|
+
.option('--full', 'Append the per-task hunt detail: commands (with gate refusals), step trace, stop arithmetic, wrap-up, sites, model, diff sizing')
|
|
447
|
+
.option('--json', 'Emit the versioned machine-readable payload ({ pullRequest, scans[].stages / findings[] / hunt.tasks[] })')
|
|
448
|
+
.action(wrap((number, options) => (0, pr_scan_1.prScan)(number, options)));
|
|
437
449
|
const criteria = program
|
|
438
450
|
.command('criteria')
|
|
439
451
|
.description('Workspace-level acceptance-criteria analytics');
|
|
@@ -625,6 +637,12 @@ taskCriteria
|
|
|
625
637
|
.command('list <task>')
|
|
626
638
|
.description('List a task\u2019s acceptance criteria (id, type, provenance, checkpointer)')
|
|
627
639
|
.action(wrap((taskId) => (0, task_criteria_list_1.taskCriteriaList)(taskId)));
|
|
640
|
+
taskCriteria
|
|
641
|
+
.command('confirm <task>')
|
|
642
|
+
.description('Record the USER\u2019s confirmation of the acceptance contract (LUM-789). Gated: without --confirm it prints a confirmation envelope listing every criterion and exits 4, writing nothing. Confirming never blocks DONE \u2014 an unconfirmed contract still closes normally.')
|
|
643
|
+
.option('--confirm', 'The user has approved this contract')
|
|
644
|
+
.option('--yes', 'Legacy alias of --confirm')
|
|
645
|
+
.action(wrap((taskId, options) => (0, task_criteria_confirm_1.taskCriteriaConfirm)(taskId, options)));
|
|
628
646
|
const taskArtifact = task
|
|
629
647
|
.command('artifact')
|
|
630
648
|
.description('Record spec-engineering artifacts (spec / plan / design …) on a task');
|