@lumoai/cli 1.57.0 → 1.59.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 +52 -130
- package/assets/skill/references/artifacts-figma.md +4 -3
- package/assets/skill/references/confirmation.md +131 -0
- package/assets/skill/references/criteria.md +60 -20
- package/assets/skill/references/doc-editing.md +11 -9
- package/assets/skill/references/docs.md +4 -3
- package/assets/skill/references/ideas.md +82 -0
- package/assets/skill/references/initiatives.md +28 -0
- 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 +32 -0
- package/assets/skill/references/sessions.md +7 -5
- package/assets/skill/references/sprints.md +18 -17
- package/assets/skill/references/task-context.md +1 -1
- package/assets/skill/references/task-deps.md +4 -3
- package/assets/skill/references/tasks.md +34 -2
- package/assets/skill/references/verify.md +46 -68
- package/assets/skill/references/worktree.md +13 -7
- 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 +165 -110
- 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 +47 -47
- package/dist/cli/src/lib/blocked-error.js +178 -0
- package/dist/cli/src/lib/confirmation.js +89 -0
- package/dist/cli/src/lib/hook-runner.js +23 -11
- package/dist/shared/src/referent-kind.js +31 -1
- package/dist/shared/src/security-scan.js +29 -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,111 @@ 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
|
+
return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} — ${(0, sanitize_1.sanitizeField)(where)} — ${title} (PR #${a.prNumber} · ${tag})`;
|
|
47
|
+
}
|
|
48
|
+
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.';
|
|
49
|
+
/** Render the `[SECURITY]` next-action lines (LUM-737 review): shared by the
|
|
50
|
+
* criteria-present Next actions block and the zero-criteria early return so
|
|
51
|
+
* undispositioned findings render identically either way. */
|
|
52
|
+
function pushSecurityActions(lines, securityActions) {
|
|
53
|
+
for (const a of securityActions) {
|
|
54
|
+
lines.push(` • [SECURITY] ${formatSecurityAction(a)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Fail-closed security coverage lines (LUM-737): a failed read and every
|
|
58
|
+
* open PR without a successful scan are stated, never left silent. */
|
|
59
|
+
const EXTERNAL_ERROR_TAIL = 200;
|
|
60
|
+
const SCAN_STAGE_ORDER = [
|
|
61
|
+
'secrets',
|
|
62
|
+
'external',
|
|
63
|
+
'supplyChain',
|
|
64
|
+
'judge',
|
|
65
|
+
'hunt',
|
|
66
|
+
];
|
|
67
|
+
/** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
|
|
68
|
+
* secrets RAN · external RAN (2 external findings) · supplyChain RAN
|
|
69
|
+
* (1 dependency finding, 1 already on main) · judge RAN · hunt RAN`.
|
|
70
|
+
* Stage segments only for keys present, in
|
|
71
|
+
* secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
|
|
72
|
+
* layer); the `(N external findings)` count only decorates the external
|
|
73
|
+
* segment, the dependency count (LUM-738, with its already-on-main share)
|
|
74
|
+
* only the supplyChain segment, and each only when N > 0. */
|
|
75
|
+
function formatScanSummaryLine(s) {
|
|
76
|
+
const segments = [];
|
|
77
|
+
for (const key of SCAN_STAGE_ORDER) {
|
|
78
|
+
const state = s.stages[key];
|
|
79
|
+
if (!state)
|
|
80
|
+
continue;
|
|
81
|
+
if (key === 'external' && s.externalFindings > 0) {
|
|
82
|
+
const noun = s.externalFindings === 1 ? 'finding' : 'findings';
|
|
83
|
+
segments.push(`external ${state} (${s.externalFindings} external ${noun})`);
|
|
84
|
+
}
|
|
85
|
+
else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
|
|
86
|
+
const n = s.dependencyFindings ?? 0;
|
|
87
|
+
const noun = n === 1 ? 'finding' : 'findings';
|
|
88
|
+
const persisting = s.persistingFindings ?? 0;
|
|
89
|
+
const tail = persisting > 0 ? `, ${persisting} already on main` : '';
|
|
90
|
+
segments.push(`supplyChain ${state} (${n} dependency ${noun}${tail})`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
segments.push(`${key} ${state}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let line = ` PR #${s.prNumber} · scan ${s.status}`;
|
|
97
|
+
if (segments.length > 0)
|
|
98
|
+
line += ` · ${segments.join(' · ')}`;
|
|
99
|
+
if (s.partial)
|
|
100
|
+
line += ' · partial';
|
|
101
|
+
// LUM-756 (P8): `error` has two writers — only the prefixed, scrubbed
|
|
102
|
+
// scanner reason may be printed, and only the text after the prefix. Stage
|
|
103
|
+
// A's raw crash text carries no prefix and is never shown here (same rule
|
|
104
|
+
// as the web panel and the PR summary).
|
|
105
|
+
const reason = s.stages.external === 'FAILED' ? (0, security_scan_1.externalFailureReason)(s.error) : null;
|
|
106
|
+
if (reason) {
|
|
107
|
+
line += ` — ${(0, sanitize_1.sanitizeField)(tail(reason, EXTERNAL_ERROR_TAIL))}`;
|
|
108
|
+
}
|
|
109
|
+
return line;
|
|
110
|
+
}
|
|
111
|
+
function pushSecurityCoverage(lines, data) {
|
|
112
|
+
if (data.securityFindings === undefined)
|
|
113
|
+
return; // older server
|
|
114
|
+
if (data.securityFindings === null) {
|
|
115
|
+
lines.push('⚠ Security-scan check failed — could not confirm whether any findings are undispositioned.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// LUM-735: absent `scans` = older server; tolerate silently, no block.
|
|
119
|
+
for (const s of data.securityFindings.scans ?? []) {
|
|
120
|
+
lines.push(formatScanSummaryLine(s));
|
|
121
|
+
}
|
|
122
|
+
for (const u of data.securityFindings.unconfirmedPrs) {
|
|
123
|
+
const why = u.reason === 'NONE'
|
|
124
|
+
? 'no security scan has run'
|
|
125
|
+
: u.reason === 'FAILED'
|
|
126
|
+
? 'security scan FAILED'
|
|
127
|
+
: `security scan still ${u.reason}`;
|
|
128
|
+
lines.push(`⚠ PR #${u.number}: ${why} — could not confirm it is clean.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
25
131
|
/**
|
|
26
132
|
* Render the acceptance status as prose. Same row grammar as
|
|
27
133
|
* `criteria list` (`<id> [TYPE] SOURCE@rN statement`) with a verdict
|
|
@@ -39,20 +145,35 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
39
145
|
if (data.escalated) {
|
|
40
146
|
lines.push('⚠ Escalated: the machine loop is exhausted — a human has been paged. Stop retrying lumo verify.');
|
|
41
147
|
}
|
|
148
|
+
// LUM-737 (whole-branch review): split once, up front, so both the
|
|
149
|
+
// zero-criteria early return below and the --full rollup can render/count
|
|
150
|
+
// undispositioned security findings without either dropping them or
|
|
151
|
+
// subtracting them from the criteria-met arithmetic.
|
|
152
|
+
const { criteria: unmetCriteria, security: securityActions } = splitNextActions(data.nextActions);
|
|
42
153
|
if (data.criteria.length === 0) {
|
|
43
154
|
lines.push('');
|
|
44
|
-
lines.push(`No acceptance criteria on ${t.identifier} — draft
|
|
155
|
+
lines.push(`No acceptance criteria on ${t.identifier} — draft them and submit with lumo task criteria set ${t.identifier} --file <criteria.json>`);
|
|
156
|
+
if (securityActions.length > 0) {
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push(`Next actions (0 unmet · ${securityActions.length} security finding${securityActions.length === 1 ? '' : 's'}):`);
|
|
159
|
+
pushSecurityActions(lines, securityActions);
|
|
160
|
+
lines.push(SECURITY_HINT);
|
|
161
|
+
}
|
|
162
|
+
pushSecurityCoverage(lines, data);
|
|
45
163
|
pushOpenCrossings(lines, extras);
|
|
46
164
|
return lines.join('\n') + '\n';
|
|
47
165
|
}
|
|
48
|
-
// LUM-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
166
|
+
// LUM-733: the default report is the agent's self-check — criteria, next
|
|
167
|
+
// actions (including undispositioned security findings, LUM-737), and any
|
|
168
|
+
// open crossings. The dashboard sections (verification rollup, history,
|
|
169
|
+
// cost, struggle trail, trend) are human-dashboard material and cost
|
|
170
|
+
// context, so they only render under --full. The claim / faithfulness
|
|
171
|
+
// columns are no longer rendered in the terminal at all (the judge still
|
|
172
|
+
// runs server-side; --json carries its fields).
|
|
173
|
+
if (extras.full)
|
|
174
|
+
pushVerificationRollup(lines, unmetCriteria.length, data);
|
|
54
175
|
lines.push('');
|
|
55
|
-
lines.push(`Criteria (${data.criteria.length} total, ${
|
|
176
|
+
lines.push(`Criteria (${data.criteria.length} total, ${unmetCriteria.length} unmet):`);
|
|
56
177
|
for (const c of data.criteria) {
|
|
57
178
|
const glyph = c.latestVerdict == null
|
|
58
179
|
? '○'
|
|
@@ -127,7 +248,7 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
127
248
|
}
|
|
128
249
|
}
|
|
129
250
|
}
|
|
130
|
-
if (data.verificationHistory.length > 0) {
|
|
251
|
+
if (extras.full && data.verificationHistory.length > 0) {
|
|
131
252
|
lines.push('');
|
|
132
253
|
lines.push('History:');
|
|
133
254
|
for (const h of data.verificationHistory) {
|
|
@@ -144,18 +265,23 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
144
265
|
}
|
|
145
266
|
}
|
|
146
267
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
268
|
+
if (extras.full) {
|
|
269
|
+
pushCost(lines, data);
|
|
270
|
+
pushStruggleTrail(lines, data);
|
|
271
|
+
pushTrend(lines, data);
|
|
272
|
+
}
|
|
150
273
|
lines.push('');
|
|
151
|
-
|
|
274
|
+
const securityLabel = securityActions.length === 0
|
|
275
|
+
? ''
|
|
276
|
+
: ` · ${securityActions.length} security finding${securityActions.length === 1 ? '' : 's'}`;
|
|
277
|
+
if (unmetCriteria.length === 0 && securityActions.length === 0) {
|
|
152
278
|
lines.push(data.currentRound > 0
|
|
153
279
|
? 'All criteria met by their latest verdicts — awaiting human adjudication.'
|
|
154
280
|
: 'Nothing unmet — but no verification has run; run `lumo verify` to judge the contract.');
|
|
155
281
|
}
|
|
156
282
|
else {
|
|
157
|
-
lines.push(`Next actions (${
|
|
158
|
-
for (const a of
|
|
283
|
+
lines.push(`Next actions (${unmetCriteria.length} unmet${securityLabel}):`);
|
|
284
|
+
for (const a of unmetCriteria) {
|
|
159
285
|
lines.push(` • [${a.verifierType}] ${(0, sanitize_1.sanitizeField)(a.statement)}`);
|
|
160
286
|
if (a.checkpointer) {
|
|
161
287
|
lines.push(` check: ${(0, sanitize_1.sanitizeField)(a.checkpointer)}`);
|
|
@@ -164,17 +290,22 @@ function formatTaskStatus(data, extras = {}) {
|
|
|
164
290
|
lines.push(` why: ${(0, sanitize_1.sanitizeField)(tail(a.rejectionReason, REASON_TAIL))}`);
|
|
165
291
|
}
|
|
166
292
|
}
|
|
293
|
+
// LUM-737: findings after the contract — constraints on DONE, not criteria.
|
|
294
|
+
pushSecurityActions(lines, securityActions);
|
|
167
295
|
if (data.escalated) {
|
|
168
296
|
lines.push('Wait for human direction before touching these.');
|
|
169
297
|
}
|
|
170
|
-
else {
|
|
171
|
-
const hasMachine =
|
|
298
|
+
else if (unmetCriteria.length > 0) {
|
|
299
|
+
const hasMachine = unmetCriteria.some(a => a.verifierType === 'MACHINE');
|
|
172
300
|
const left = data.maxRounds - data.currentRound;
|
|
173
301
|
lines.push(hasMachine
|
|
174
302
|
? `Fix the unmet criteria, then run \`lumo verify\` (${left} round${left === 1 ? '' : 's'} left).`
|
|
175
303
|
: 'Remaining criteria are HUMAN-only — finish the work and hand off for human review.');
|
|
176
304
|
}
|
|
305
|
+
if (securityActions.length > 0)
|
|
306
|
+
lines.push(SECURITY_HINT);
|
|
177
307
|
}
|
|
308
|
+
pushSecurityCoverage(lines, data);
|
|
178
309
|
pushOpenCrossings(lines, extras);
|
|
179
310
|
return lines.join('\n') + '\n';
|
|
180
311
|
}
|
|
@@ -222,104 +353,27 @@ function fmtDuration(totalSec) {
|
|
|
222
353
|
* claim text is same-source with the web card (latest session's LLM run
|
|
223
354
|
* summary, else its STOP turn digest) so the two surfaces cannot drift.
|
|
224
355
|
*/
|
|
225
|
-
|
|
356
|
+
/**
|
|
357
|
+
* LUM-733: the measured verification rollup (--full only) — machine-verified
|
|
358
|
+
* vs human-override over the MACHINE criteria, and how many criteria are met.
|
|
359
|
+
* The former "Claim vs verification" pairing (claim text + faithfulness
|
|
360
|
+
* verdict) is no longer rendered in the terminal.
|
|
361
|
+
*/
|
|
362
|
+
function pushVerificationRollup(lines, unmetCriteriaCount, data) {
|
|
226
363
|
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):');
|
|
364
|
+
lines.push('Verification:');
|
|
263
365
|
const mv = data.machineVerification;
|
|
264
366
|
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(', ')}`);
|
|
367
|
+
lines.push(' no verification has run yet');
|
|
368
|
+
return;
|
|
314
369
|
}
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
.slice(0, 3)
|
|
318
|
-
.map(s => (0, sanitize_1.sanitizeField)(s).slice(0, 7))
|
|
319
|
-
.join(' '));
|
|
370
|
+
if (mv.total > 0) {
|
|
371
|
+
lines.push(` ${mv.machineVerified} machine-verified / ${mv.humanOverridden} human override (of ${mv.total} MACHINE criteria)`);
|
|
320
372
|
}
|
|
321
|
-
|
|
322
|
-
|
|
373
|
+
// LUM-737 (whole-branch review): `unmetCriteriaCount` excludes SECURITY_FINDING
|
|
374
|
+
// next actions — met/unmet counts criteria only, never security findings.
|
|
375
|
+
const met = data.criteria.length - unmetCriteriaCount;
|
|
376
|
+
lines.push(` ${met} of ${data.criteria.length} criteria met by their latest verdict`);
|
|
323
377
|
}
|
|
324
378
|
/**
|
|
325
379
|
* Append the honest "Cost" section (LUM-560) — 规律 1: surface the costs a human
|
|
@@ -619,6 +673,7 @@ async function taskStatus(identifier, options = {}) {
|
|
|
619
673
|
return;
|
|
620
674
|
}
|
|
621
675
|
process.stdout.write(formatTaskStatus(data, {
|
|
676
|
+
full: options.full === true,
|
|
622
677
|
openCrossings: crossingsResult,
|
|
623
678
|
dispositionUrl: (0, open_crossings_1.dispositionUrl)(base, creds.workspaceSlug ?? 'lumo', data.task.identifier),
|
|
624
679
|
// 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 = [];
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.describeWorktreeRm = describeWorktreeRm;
|
|
3
4
|
exports.worktreeRm = worktreeRm;
|
|
4
5
|
const worktree_1 = require("../lib/worktree");
|
|
5
6
|
const worktree_ref_1 = require("../lib/worktree-ref");
|
|
7
|
+
const confirmation_1 = require("../lib/confirmation");
|
|
8
|
+
/**
|
|
9
|
+
* The changes[] block for the confirmation envelope. `dirty` adds the
|
|
10
|
+
* discard warning that --force acknowledges; `deleteBranch` flips the branch
|
|
11
|
+
* line from "kept" to "deleted".
|
|
12
|
+
*/
|
|
13
|
+
function describeWorktreeRm(args) {
|
|
14
|
+
const lines = [
|
|
15
|
+
`Will remove worktree ${args.path}${args.branch ? ` (branch ${args.branch})` : ''}`,
|
|
16
|
+
];
|
|
17
|
+
if (args.dirty) {
|
|
18
|
+
lines.push('⚠ The worktree has uncommitted changes — they will be discarded (--force acknowledges this)');
|
|
19
|
+
}
|
|
20
|
+
if (args.branch) {
|
|
21
|
+
lines.push(args.deleteBranch
|
|
22
|
+
? `Branch ${args.branch} will be deleted too (--delete-branch; git refuses an unmerged branch)`
|
|
23
|
+
: `Branch ${args.branch} is kept (pass --delete-branch to delete it)`);
|
|
24
|
+
}
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
6
27
|
async function worktreeRm(rawId, opts) {
|
|
7
28
|
let taskId;
|
|
8
29
|
try {
|
|
@@ -27,14 +48,21 @@ async function worktreeRm(rawId, opts) {
|
|
|
27
48
|
console.error(`Error: no worktree found for ${taskId}`);
|
|
28
49
|
return 1;
|
|
29
50
|
}
|
|
30
|
-
if (!opts.yes) {
|
|
31
|
-
console.error(`Error: refusing to remove ${target.path} without --yes (destructive)`);
|
|
32
|
-
return 1;
|
|
33
|
-
}
|
|
34
51
|
const dirty = (0, worktree_1.gitOutput)(['status', '--porcelain'], target.path).length > 0;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
// LUM-755: two gates, one envelope. Missing --confirm, or a dirty tree
|
|
53
|
+
// without --force, both stop here with the same structured refusal; the
|
|
54
|
+
// confirmCommand carries whichever flags are still missing.
|
|
55
|
+
if (!(0, confirmation_1.isConfirmed)(opts) || (dirty && !opts.force)) {
|
|
56
|
+
return (0, confirmation_1.emitConfirmation)({
|
|
57
|
+
command: 'worktree rm',
|
|
58
|
+
changes: describeWorktreeRm({
|
|
59
|
+
path: target.path,
|
|
60
|
+
branch: target.branch ?? null,
|
|
61
|
+
dirty,
|
|
62
|
+
deleteBranch: Boolean(opts.deleteBranch),
|
|
63
|
+
}),
|
|
64
|
+
extraFlags: dirty ? ['--force'] : [],
|
|
65
|
+
});
|
|
38
66
|
}
|
|
39
67
|
const removeArgs = dirty && opts.force
|
|
40
68
|
? ['worktree', 'remove', '--force', target.path]
|