@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.
Files changed (46) hide show
  1. package/assets/skill/SKILL.md +52 -130
  2. package/assets/skill/references/artifacts-figma.md +4 -3
  3. package/assets/skill/references/confirmation.md +131 -0
  4. package/assets/skill/references/criteria.md +60 -20
  5. package/assets/skill/references/doc-editing.md +11 -9
  6. package/assets/skill/references/docs.md +4 -3
  7. package/assets/skill/references/ideas.md +82 -0
  8. package/assets/skill/references/initiatives.md +28 -0
  9. package/assets/skill/references/memory.md +4 -2
  10. package/assets/skill/references/milestones.md +3 -2
  11. package/assets/skill/references/outcome.md +1 -14
  12. package/assets/skill/references/plan-runs.md +32 -0
  13. package/assets/skill/references/sessions.md +7 -5
  14. package/assets/skill/references/sprints.md +18 -17
  15. package/assets/skill/references/task-context.md +1 -1
  16. package/assets/skill/references/task-deps.md +4 -3
  17. package/assets/skill/references/tasks.md +34 -2
  18. package/assets/skill/references/verify.md +46 -68
  19. package/assets/skill/references/worktree.md +13 -7
  20. package/dist/cli/src/commands/doc-delete.js +35 -23
  21. package/dist/cli/src/commands/doc-rebuild-source.js +16 -4
  22. package/dist/cli/src/commands/memory-rm.js +68 -10
  23. package/dist/cli/src/commands/milestone-delete.js +13 -10
  24. package/dist/cli/src/commands/outcome.js +0 -77
  25. package/dist/cli/src/commands/session-attach.js +8 -2
  26. package/dist/cli/src/commands/sprint-close.js +29 -9
  27. package/dist/cli/src/commands/sprint-delete.js +13 -10
  28. package/dist/cli/src/commands/sprint-show.js +3 -9
  29. package/dist/cli/src/commands/task-artifact-rm.js +58 -28
  30. package/dist/cli/src/commands/task-criteria-list.js +1 -4
  31. package/dist/cli/src/commands/task-criteria-set.js +3 -12
  32. package/dist/cli/src/commands/task-deps.js +20 -6
  33. package/dist/cli/src/commands/task-status.js +165 -110
  34. package/dist/cli/src/commands/task-update.js +129 -0
  35. package/dist/cli/src/commands/verify.js +22 -13
  36. package/dist/cli/src/commands/worktree-rm.js +35 -7
  37. package/dist/cli/src/index.js +47 -47
  38. package/dist/cli/src/lib/blocked-error.js +178 -0
  39. package/dist/cli/src/lib/confirmation.js +89 -0
  40. package/dist/cli/src/lib/hook-runner.js +23 -11
  41. package/dist/shared/src/referent-kind.js +31 -1
  42. package/dist/shared/src/security-scan.js +29 -0
  43. package/package.json +1 -1
  44. package/assets/skill/references/fidelity.md +0 -32
  45. package/dist/cli/src/commands/fidelity.js +0 -108
  46. 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 3–7 and submit with lumo task criteria set ${t.identifier} --file <criteria.json>`);
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-564 声称 vs 核验: the agent's CLAIM (what it says it did) paired with
49
- // the measured verification conclusion (what was actually confirmed) the
50
- // two columns regular 規律2 wants, instead of only the verification one. The
51
- // machine-verification rollup (LUM-470) lives on the verification side here
52
- // rather than as a standalone line, so the contrast is in one place.
53
- pushClaimVsVerification(lines, data);
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, ${data.nextActions.length} unmet):`);
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
- pushCost(lines, data);
148
- pushStruggleTrail(lines, data);
149
- pushTrend(lines, data);
268
+ if (extras.full) {
269
+ pushCost(lines, data);
270
+ pushStruggleTrail(lines, data);
271
+ pushTrend(lines, data);
272
+ }
150
273
  lines.push('');
151
- if (data.nextActions.length === 0) {
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 (${data.nextActions.length} unmet):`);
158
- for (const a of data.nextActions) {
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 = data.nextActions.some(a => a.verifierType === 'MACHINE');
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
- function pushClaimVsVerification(lines, data) {
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('Claim vs verification:');
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
- // Nothing verified yet — the claim stands unconfirmed. Don't imply a pass.
266
- lines.push(' no verification has run yet — the claim is unconfirmed');
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 (f.diffShas.length > 0) {
316
- ev.push(f.diffShas
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
- lines.push(' ▸ Faithfulness does the claim match the delivery (LUM-583):');
322
- lines.push(` ${body}${ev.length > 0 ? ` · evidence: ${ev.join(' · ')}` : ''}`);
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
- // ── Require the self-report (LUM-597) ────────────────────────────────────
148
- // Requesting acceptance is a structured action; `--note` is the agent's
149
- // mandatory self-report. It is captured deterministically here (the agent
150
- // can't be made to phrase it well, but it can be made to provide one); its
151
- // truthfulness is the faithfulness audit's job downstream. No note → no round
152
- // is posted (no round burned), so re-running with one is free.
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
- if (dirty && !opts.force) {
36
- console.error(`Error: ${target.path} has uncommitted changes pass --force to remove anyway`);
37
- return 1;
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]