@lumoai/cli 1.66.0 → 1.68.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.
@@ -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
- * Dependencies: checked (1 dependency finding, 1 already on main) ·
68
- * AI review: checked · Exploit paths: checked`, plus, on a merged / closed
69
- * PR, ` · 3 left the queue (2 resolved on main, 1 merged unreviewed)`
70
- * (LUM-775).
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
- const segments = [];
81
- for (const key of SCAN_STAGE_ORDER) {
82
- const state = s.stages[key];
83
- if (!state)
84
- continue;
85
- const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
86
- if (key === 'external' && s.externalFindings > 0) {
87
- const noun = s.externalFindings === 1 ? 'finding' : 'findings';
88
- segments.push(`${head} (${s.externalFindings} external ${noun})`);
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 so REVIEW_ADDED
173
- * provenance stays explicitly visible in every row.
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(' (no verdict yet)');
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
- ? 'All criteria met by their latest verdicts — awaiting human adjudication.'
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
- : 'Remaining criteria are HUMAN-only finish the work and hand off for human review.');
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 by their latest verdict`);
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
- `The contract is HUMAN-only; finish your work and hand off for human review (lumo task update ${taskId} --status in_review).\n`);
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) ───────────────────────────────────────
@@ -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');
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatScanLayerTail = formatScanLayerTail;
4
+ exports.formatHuntCoverageLine = formatHuntCoverageLine;
5
+ const sanitize_1 = require("./sanitize");
6
+ const security_scan_1 = require("../../../shared/src/security-scan");
7
+ const EXTERNAL_ERROR_TAIL = 200;
8
+ function tail(s, max) {
9
+ return s.length > max ? `…${s.slice(-max)}` : s;
10
+ }
11
+ /**
12
+ * ` · Secrets: checked · Code scan: checked (2 external findings) ·
13
+ * Dependencies: checked (1 dependency finding, 1 already on main) ·
14
+ * AI review: checked · Exploit paths: checked · partial · 3 left the queue
15
+ * (2 resolved on main, 1 merged unreviewed) — <scrubbed reason>`, or `''`
16
+ * when the scan carries no stage at all. The caller prefixes its own head
17
+ * (` PR #945 · scan CLEAN` / `Scan abc1234 · FINDINGS`).
18
+ *
19
+ * Stage segments only for keys present, in secrets/external/supplyChain/
20
+ * judge/hunt order; the `(N external findings)` count only decorates the
21
+ * Code scan segment, the dependency count (with its already-on-main share)
22
+ * only the Dependencies segment, and each only when N > 0.
23
+ */
24
+ function formatScanLayerTail(s) {
25
+ const segments = [];
26
+ for (const key of security_scan_1.SCAN_STAGE_KEYS) {
27
+ const state = s.stages[key];
28
+ if (!state)
29
+ continue;
30
+ const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
31
+ if (key === 'external' && s.externalFindings > 0) {
32
+ const noun = s.externalFindings === 1 ? 'finding' : 'findings';
33
+ segments.push(`${head} (${s.externalFindings} external ${noun})`);
34
+ }
35
+ else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
36
+ const n = s.dependencyFindings ?? 0;
37
+ const noun = n === 1 ? 'finding' : 'findings';
38
+ const persisting = s.persistingFindings ?? 0;
39
+ const tail = persisting > 0 ? `, ${persisting} already on main` : '';
40
+ segments.push(`${head} (${n} dependency ${noun}${tail})`);
41
+ }
42
+ else {
43
+ segments.push(head);
44
+ }
45
+ }
46
+ let line = '';
47
+ if (segments.length > 0)
48
+ line += ` · ${segments.join(' · ')}`;
49
+ if (s.partial)
50
+ line += ' · partial';
51
+ // LUM-775: what left the queue because the PR merged / closed, and why —
52
+ // so a merged PR's line explains where its findings went instead of
53
+ // silently listing fewer open rows than the scan found.
54
+ const exits = (0, security_scan_1.formatQueueExits)(s.queueExits);
55
+ if (exits)
56
+ line += ` · ${exits}`;
57
+ // LUM-756 (P8): only the prefixed, scrubbed scanner reason is printed, and
58
+ // only the text after the prefix (the caller has already applied the rule).
59
+ if (s.externalFailureReason) {
60
+ line += ` — ${(0, sanitize_1.sanitizeField)(tail(s.externalFailureReason, EXTERNAL_ERROR_TAIL))}`;
61
+ }
62
+ return line;
63
+ }
64
+ /**
65
+ * LUM-762 — the hunt's per-task read-out, one line under its scan:
66
+ * `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
67
+ * (2 attempts) · t3 race skipped`. Headed by the same display label as the
68
+ * layer's own segment (LUM-763) — the detail line and the line above it
69
+ * must not name the layer two different ways. Elapsed time is summed over a
70
+ * task's attempts; a skipped task has none to print.
71
+ *
72
+ * LUM-758: a plan may hold two tasks of the same category, so the row is led
73
+ * by its task id. Pre-task-graph audits carry no id — the row then prints
74
+ * exactly as it always did.
75
+ */
76
+ function formatHuntCoverageLine(hunt) {
77
+ const parts = hunt.categories.map(c => {
78
+ const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
79
+ const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
80
+ const id = c.taskId === undefined ? '' : `${(0, sanitize_1.sanitizeField)(c.taskId)} `;
81
+ return `${id}${(0, sanitize_1.sanitizeField)(c.category)} ${spent}${(0, sanitize_1.sanitizeField)(c.stopped)}${attempts}`;
82
+ });
83
+ return `${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
84
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUnmetForDisplay = isUnmetForDisplay;
4
+ /** PASS 与 PASS_WITH_FOLLOWUP 都算通过(与 verification-run 仓库的
5
+ * isPassVerdict 同义;这里本地实现以保持本模块零依赖)。 */
6
+ function isPass(verdict) {
7
+ return verdict === 'PASS' || verdict === 'PASS_WITH_FOLLOWUP';
8
+ }
9
+ /**
10
+ * LUM-789 渲染口径:一条 criterion 是否该呈现为「未满足」。
11
+ *
12
+ * 未裁定的 HUMAN 项是 **tacit pass** —— 没人送回就是默认通过,与 DONE 闸
13
+ * 早已采用的口径一致(task-state.service.ts 的 assertNoUnresolvedSendBack
14
+ * 只对明确 FAIL 返回 409)。把它呈现成待办是纯摩擦:一个总会通过的闸产出
15
+ * 的不是信号,是伪装成信号的噪音。
16
+ *
17
+ * ⚠️ 这是 **渲染** 口径,不是裁决口径。裁决用的是
18
+ * task-human-verdict.service.ts 的 `allMet`(every criterion isPassVerdict),
19
+ * 它必须保持「未裁定 ≠ 通过」,否则在 web 上点一条 PASS 会把整个任务推成
20
+ * DONE。两者不得合并。
21
+ */
22
+ function isUnmetForDisplay(criterion, latest) {
23
+ if (isPass(latest?.verdict))
24
+ return false;
25
+ if (latest === undefined && criterion.verifierType === 'HUMAN')
26
+ return false;
27
+ return true;
28
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ /**
3
+ * LUM-788 — the per-task hunt read-out, shared by the CLI (`lumo pr scan
4
+ * --full`) and the lum739 analysis script. One implementation: the CLI is a
5
+ * separate package that cannot import `lib/` or `scripts/`, and a second copy
6
+ * of this printer is exactly how the audit lost four fields to "computed,
7
+ * carried, never read" before LUM-758.
8
+ *
9
+ * Pure formatting over the raw `PrSecurityScan.huntAudit.tasks[]` rows. Every
10
+ * field is optional by construction: the column is Json written by past
11
+ * versions of the workflow, and a row written before a field existed prints
12
+ * exactly as it always did — nothing is guessed, nothing is thrown over.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.formatHuntTaskDetail = formatHuntTaskDetail;
16
+ exports.formatShellDetail = formatShellDetail;
17
+ /** One line of prose, flattened, for a fixed-width read-out. */
18
+ function oneLine(text, max) {
19
+ const flat = text.replace(/\s+/g, ' ').trim();
20
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
21
+ }
22
+ /**
23
+ * The per-task listing for EVERY task handed in, idle ones included — the
24
+ * `lumo pr scan --full` shape. A task that never asked the shell for anything
25
+ * still has a stop line, a model and the sites it was briefed on, and "this
26
+ * task ran two steps and submitted nothing" is a fact the reader came for.
27
+ *
28
+ * Lines per task: a header (`t1 done · <model> · 3 sites · 2 cmds (gate
29
+ * refused …)`), one line per command (`✗` = the gate refused it, with its
30
+ * reason), one line per trace step, then the stop arithmetic and the
31
+ * wrap-up outcome — each only when the row carries it.
32
+ */
33
+ function formatHuntTaskDetail(tasks, maxTasks) {
34
+ const out = [];
35
+ for (const t of tasks.slice(0, maxTasks)) {
36
+ const sh = t.shell;
37
+ // LUM-787 — the model and the sites print only when the row carries them;
38
+ // the clip count likewise: an older row, and a row from a search that
39
+ // never hit the ceiling, read as before.
40
+ const cmds = sh?.calls
41
+ ? `${sh.calls} cmds (gate refused ${sh.refused ?? 0}, sandbox failed ${sh.failed ?? 0}, not found ${sh.notFound ?? 0}, empty ${sh.empty ?? 0}${sh.stepClipped ? `, step-clipped ${sh.stepClipped}` : ''})`
42
+ : 'no shell commands';
43
+ out.push(`${String(t.taskId)} ${String(t.stopped)}${t.model ? ` · ${t.model}` : ''}${typeof t.sites === 'number' ? ` · ${t.sites} sites` : ''} · ${cmds}`);
44
+ for (const c of sh?.commands ?? []) {
45
+ const cost = typeof c.chars === 'number'
46
+ ? ` [${c.chars} chars${c.compressed ? ', compressed' : ''}${c.clipped ? ', clipped' : ''}]`
47
+ : '';
48
+ out.push(` ${c.denied ? '✗' : ' '} ${c.cmd ?? ''}${cost}${c.reason ? ` ← ${c.reason}` : ''}`);
49
+ }
50
+ // LUM-781 — the model's side, step by step, then how it ended. Only when
51
+ // the row carries them: an older row prints exactly as before.
52
+ for (const st of t.trace ?? []) {
53
+ out.push(` #${st.i ?? '?'} tool=${st.tool ?? '(none)'} in=${st.inputTokens ?? '?'} out=${st.outputTokens ?? '?'}${st.reasoningTokens ? ` reasoning=${st.reasoningTokens}` : ''}${st.elidedChars ? ` elided=${st.elidedChars}` : ''}${st.finishReason ? ` finish=${st.finishReason}` : ''}${st.text ? ` "${oneLine(st.text, 160)}"` : ''}`);
54
+ }
55
+ if (t.stop) {
56
+ const s = t.stop;
57
+ out.push(` stop: ${s.by ?? '?'} · spent ${s.spent ?? '?'}/${s.budget ?? '?'} · reserve ${s.reserve ?? 0} · last step ${s.lastStep ?? '?'}${typeof s.next === 'number' ? ` · next ${s.next}` : ''} · ${s.steps ?? '?'} steps`);
58
+ }
59
+ if (t.wrapUp) {
60
+ const w = t.wrapUp;
61
+ out.push(w.ran
62
+ ? ` wrap-up: ran · submitted ${w.submitted ?? 0} · ${w.tokens ?? '?'} tokens${w.finishReason ? ` · finish=${w.finishReason}` : ''}${w.text ? ` · "${oneLine(w.text, 200)}"` : ''}`
63
+ : w.error
64
+ ? ` wrap-up: attempted, failed — ${oneLine(w.error, 160)}`
65
+ : ` wrap-up: not made (${w.skipped ?? 'unknown'})`);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * The per-task, command-by-command listing of the lum739 audit report —
72
+ * only tasks that actually asked the shell for something.
73
+ *
74
+ * The run-level `shell:` line says HOW MUCH; only this says WHAT — and telling
75
+ * "mapped the repo and ran out" from "the gate rejected everything" from
76
+ * "looked and found nothing" is the entire reason the field exists. Extracted
77
+ * as a pure function so it can be tested: a read-out nothing exercises is the
78
+ * same "computed, carried, never read" shape this layer has already lost four
79
+ * fields to.
80
+ */
81
+ function formatShellDetail(tasks, maxTasks) {
82
+ return formatHuntTaskDetail(tasks.filter(x => x.shell?.calls), maxTasks);
83
+ }