@lumoai/cli 1.46.0 → 1.48.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.
@@ -229,19 +229,35 @@ function pushClaimVsVerification(lines, data) {
229
229
  lines.push(' ▸ Claim — what the agent says it did');
230
230
  lines.push(' (agent self-report · estimated, not verification):');
231
231
  const claim = data.claim;
232
- if (claim && claim.text) {
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.
233
235
  for (const cl of (0, sanitize_1.sanitizeField)(claim.text).split('\n')) {
234
236
  lines.push(` ${cl}`);
235
237
  }
236
- lines.push(claim.source === 'RUN_SUMMARY'
237
- ? ' ↳ source: LLM run summary'
238
- : ' ↳ source: raw turn digest no LLM summary yet');
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');
239
250
  }
240
251
  else {
241
252
  // Fail-closed: no run summary → say so, never invent a claim. Run summaries
242
253
  // are synthesized when the bound task reaches DONE (LUM-481).
243
254
  lines.push(' not generated yet — the agent run summary is synthesized when the task reaches DONE');
244
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);
245
261
  // ── Verification (核验): the measured verdict — machine-verified vs override.
246
262
  lines.push(' ▸ Verification — what was actually confirmed (measured):');
247
263
  const mv = data.machineVerification;
@@ -257,6 +273,54 @@ function pushClaimVsVerification(lines, data) {
257
273
  lines.push(` ${met} of ${data.criteria.length} criteria met by their latest verdict`);
258
274
  }
259
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(', ')}`);
314
+ }
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(' '));
320
+ }
321
+ lines.push(' ▸ Faithfulness — does the claim match the delivery (LUM-583):');
322
+ lines.push(` ${body}${ev.length > 0 ? ` · evidence: ${ev.join(' · ')}` : ''}`);
323
+ }
260
324
  /**
261
325
  * Append the honest "Cost" section (LUM-560) — 规律 1: surface the costs a human
262
326
  * should weigh (token spend, active time, machine rework) on the same report as
@@ -144,6 +144,19 @@ async function verify(identifier, options = {}) {
144
144
  `The contract is HUMAN-only; finish your work and hand off for human review (lumo task update ${taskId} --status in_review).\n`);
145
145
  return;
146
146
  }
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
+ }
147
160
  // ── Execute every checkpointer locally ───────────────────────────────────
148
161
  process.stdout.write(`Verifying ${taskId} — ${machine.length} MACHINE criteria\n`);
149
162
  const results = [];
@@ -165,7 +178,7 @@ async function verify(identifier, options = {}) {
165
178
  res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/verify`, {
166
179
  method: 'POST',
167
180
  headers: { ...headers, 'Content-Type': 'application/json' },
168
- body: JSON.stringify({ results }),
181
+ body: JSON.stringify({ results, note }),
169
182
  });
170
183
  }
171
184
  catch (err) {
@@ -47,9 +47,11 @@ const session_attach_1 = require("./commands/session-attach");
47
47
  const session_status_1 = require("./commands/session-status");
48
48
  const next_1 = require("./commands/next");
49
49
  const cost_1 = require("./commands/cost");
50
+ const criteria_audit_1 = require("./commands/criteria-audit");
50
51
  const verify_1 = require("./commands/verify");
51
52
  const verdict_1 = require("./commands/verdict");
52
53
  const crossing_explain_1 = require("./commands/crossing-explain");
54
+ const outcome_1 = require("./commands/outcome");
53
55
  const task_context_1 = require("./commands/task-context");
54
56
  const task_create_1 = require("./commands/task-create");
55
57
  const task_update_1 = require("./commands/task-update");
@@ -223,6 +225,7 @@ program
223
225
  .command('verify [task]')
224
226
  .description('Machine verification loop (LUM-343): run every MACHINE criterion checkpointer locally, report structured verdicts to the server (round cap 3), and print next actions. All-pass moves the task to IN_REVIEW. Defaults to the session-bound task.')
225
227
  .option('--timeout <seconds>', 'Per-checkpointer timeout in seconds (default 600)')
228
+ .option('--note <text>', 'Required self-report — what you did and why it is ready (LUM-597). Recorded as your claim (source AGENT) when the round passes into IN_REVIEW, and checked against the diff for faithfulness.')
226
229
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
227
230
  program
228
231
  .command('verdict [task]')
@@ -241,6 +244,26 @@ crossing
241
244
  .description('Append an agent self-explanation ("申辩") to a boundary crossing (LUM-542). Append-only and for the human reviewer — it never clears the crossing or unblocks Done (a human dispositions that). Targets a crossing on the session-bound task.')
242
245
  .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
243
246
  .action(wrap((id, options) => (0, crossing_explain_1.crossingExplain)(id, options)));
247
+ const outcome = program
248
+ .command('outcome')
249
+ .description('Read / record the post-hoc outcome well (LUM-598)');
250
+ outcome
251
+ .command('show <task>')
252
+ .description('Read the post-hoc outcome well for a task: the falsifier verdict (REJECTED | INCONCLUSIVE — never a "pass") plus its backing rejection signals. INCONCLUSIVE means no rejection on record, NOT satisfied.')
253
+ .action(wrap((task) => (0, outcome_1.outcomeShow)(task)));
254
+ outcome
255
+ .command('record <task>')
256
+ .description('Record a human-observed post-hoc REJECTION of a delivery (revert / rollback / CI regression / downstream redirect / bypass). Append-only; there is no "mark satisfied" counterpart — the well only asserts rejection.')
257
+ .requiredOption('--note <text>', 'What reality did (the observed referent), e.g. "reverted in #812 after a prod incident"')
258
+ .option('--kind <kind>', 'reverted | rolled_back | ci_regression | downstream_redirect | bypassed | manual (case-insensitive; default: manual)')
259
+ .option('--occurred-at <iso>', 'When the event happened (ISO 8601; defaults to now)')
260
+ .action(wrap((task, options) => (0, outcome_1.outcomeRecord)(task, options)));
261
+ outcome
262
+ .command('rate')
263
+ .description('Trust × post-hoc fate: per delivery-time forecast-confidence bracket, the post-hoc REJECTED rate from the outcome well. Honest by construction — thin brackets read "insufficient" and the high-vs-low comparison stays "inconclusive" until the well has enough signal. The rate is a LOWER BOUND (no signal = INCONCLUSIVE, never satisfied).')
264
+ .option('--min <N>', 'Per-bracket sample floor below which a rate is withheld (default 10)')
265
+ .option('--json', 'Emit the report as JSON')
266
+ .action(wrap(options => (0, outcome_1.outcomeRate)(options)));
244
267
  program
245
268
  .command('next')
246
269
  .description('Recommend the next task(s) to work on, ranked by priority, active sprint, and due date. Prints top N (default 3); pick one and run `session attach` + `task context`.')
@@ -255,6 +278,13 @@ program
255
278
  .option('--by <dim>', 'Headline grouping: tool | model | member | session (case-insensitive; default tool)')
256
279
  .option('--json', 'Emit the versioned payload as JSON')
257
280
  .action(wrap(options => (0, cost_1.cost)(options)));
281
+ const criteria = program
282
+ .command('criteria')
283
+ .description('Workspace-level acceptance-criteria analytics');
284
+ criteria
285
+ .command('audit')
286
+ .description('Show the workspace referent-kind distribution + self-confirming-green ratio (GET /api/criteria/audit)')
287
+ .action(wrap(() => (0, criteria_audit_1.criteriaAudit)()));
258
288
  const session = program
259
289
  .command('session')
260
290
  .description('Manage per-terminal coding-session context');
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REFERENT_KIND_DECLARABLE = void 0;
4
+ exports.classifyCheckpointerGrounding = classifyCheckpointerGrounding;
5
+ exports.effectiveReferentKind = effectiveReferentKind;
6
+ /**
7
+ * referentKind (LUM-602): what a criterion anchors — the only dimension that
8
+ * determines independence. Dependency-light (no @prisma/client) so both the
9
+ * Next.js server and the CLI can import it; the Prisma `ReferentKind` enum
10
+ * mirrors REFERENT_KIND_DECLARABLE exactly.
11
+ */
12
+ exports.REFERENT_KIND_DECLARABLE = [
13
+ 'EXTERNAL_FACT',
14
+ 'AGENT_CONSTRUCTED_STATE',
15
+ 'PENDING_OUTCOME',
16
+ ];
17
+ /** Tools whose output the agent cannot author — external facts. */
18
+ const EXTERNAL_TOOLS = [
19
+ 'git',
20
+ 'gh',
21
+ 'curl',
22
+ 'wget',
23
+ 'http',
24
+ 'https',
25
+ 'psql',
26
+ 'dig',
27
+ 'nc',
28
+ ];
29
+ /** Agent's own test/type/build harness — green here is self-confirming. */
30
+ const HARNESS_TOOLS = [
31
+ 'jest',
32
+ 'vitest',
33
+ 'mocha',
34
+ 'pytest',
35
+ 'tsc',
36
+ 'node',
37
+ 'ts-node',
38
+ 'tsx',
39
+ ];
40
+ /**
41
+ * `prisma migrate status` is EXTERNAL (queries DB/migration history); a bare
42
+ * `prisma generate` is harness. Matched as a two-word phrase before the
43
+ * single-word scan.
44
+ */
45
+ const EXTERNAL_PHRASES = ['prisma migrate status'];
46
+ function wordMatches(word, tools) {
47
+ // exact tool, or a tool-prefixed filename like jest-t.ts / vitest.config
48
+ return tools.some(t => word === t || word.startsWith(`${t}-`) || word.startsWith(`${t}.`));
49
+ }
50
+ /**
51
+ * Given a raw command word (possibly `npx jest`, `npm run build`, etc.),
52
+ * normalize to the effective tool name. Returns null for pure wrapper tokens
53
+ * (npx, sudo, command) that should cause us to advance to the next word.
54
+ */
55
+ function normalizeCommandWord(word) {
56
+ // strip a leading path: scripts/jest-t.ts → jest-t.ts (keep basename)
57
+ const slash = word.lastIndexOf('/');
58
+ return slash >= 0 ? word.slice(slash + 1) : word;
59
+ }
60
+ /**
61
+ * Extract command-position words from a shell script string.
62
+ *
63
+ * Algorithm:
64
+ * 1. Strip `#` comments (everything from # to end of line).
65
+ * 2. Extract `$(...)` and backtick substitutions — they are command
66
+ * lists; recurse into them and add their command-position words.
67
+ * 3. For `bash -c '...'` / `sh -c '...'` tokens, extract the quoted
68
+ * payload and recurse into it as a command list.
69
+ * 4. Split the remaining text on command separators (|, &&, ||, ;, \n)
70
+ * to get individual command segments.
71
+ * 5. From each segment, take the first meaningful word as the command.
72
+ * Skip wrapper words (npx, sudo, command) and "npm run" pairs.
73
+ */
74
+ function collectCommandWords(script) {
75
+ const result = [];
76
+ // Step 1: strip # comments (not inside quotes — simple heuristic: strip
77
+ // from # that is preceded by whitespace or start-of-line to end of line)
78
+ const noComments = script.replace(/(^|\s)#[^\n]*/gm, ' ');
79
+ // Step 2: recurse into $(...) substitutions
80
+ // We use a simple balanced-paren extractor.
81
+ let remaining = noComments;
82
+ remaining = remaining.replace(/\$\(([^)]*)\)/g, (_match, inner) => {
83
+ result.push(...collectCommandWords(inner));
84
+ return ' ';
85
+ });
86
+ // Recurse into backtick substitutions
87
+ remaining = remaining.replace(/`([^`]*)`/g, (_match, inner) => {
88
+ result.push(...collectCommandWords(inner));
89
+ return ' ';
90
+ });
91
+ // Step 3: handle bash -c / sh -c with a single-quoted payload.
92
+ // Pattern: (bash|sh) ... -c '...' or (bash|sh) ... -c "..."
93
+ // We do this BEFORE splitting on separators so the payload content
94
+ // doesn't pollute the segment split.
95
+ remaining = remaining.replace(/\b(?:bash|sh)\b[^'"]*-c\s+'([^']*)'/g, (_match, payload) => {
96
+ result.push(...collectCommandWords(payload));
97
+ return ' ';
98
+ });
99
+ remaining = remaining.replace(/\b(?:bash|sh)\b[^'"]*-c\s+"([^"]*)"/g, (_match, payload) => {
100
+ result.push(...collectCommandWords(payload));
101
+ return ' ';
102
+ });
103
+ // Step 4: split on command separators to get segments.
104
+ // Split on |, &&, ||, ;, newlines. Note: || must be checked before |.
105
+ const segments = remaining.split(/\|\||&&|[|;\n]/);
106
+ // Step 5: from each segment extract the command word.
107
+ for (const seg of segments) {
108
+ // Strip leading/trailing whitespace and any residual shell chars
109
+ // (quotes, $, brackets not already consumed).
110
+ const cleaned = seg
111
+ .replace(/['"]/g, ' ') // remove remaining quotes (string args)
112
+ .replace(/\[|\]/g, ' ') // remove [ ] test brackets
113
+ .replace(/\$\{?[^}]*}?/g, ' ') // remove remaining $VAR references
114
+ .trim();
115
+ if (!cleaned)
116
+ continue;
117
+ const tokens = cleaned.split(/\s+/).filter(Boolean);
118
+ // Walk tokens to find the effective command, skipping wrappers.
119
+ let i = 0;
120
+ while (i < tokens.length) {
121
+ const tok = tokens[i];
122
+ const lower = tok.toLowerCase();
123
+ if (lower === 'npx' || lower === 'sudo' || lower === 'command') {
124
+ i++;
125
+ continue;
126
+ }
127
+ if (lower === 'npm' && tokens[i + 1]?.toLowerCase() === 'run') {
128
+ // "npm run <script>" — script name is the command
129
+ i += 2;
130
+ continue;
131
+ }
132
+ // This is the effective command
133
+ const word = normalizeCommandWord(lower);
134
+ if (word)
135
+ result.push(word);
136
+ break;
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+ function classifyCheckpointerGrounding(checkpointer) {
142
+ if (!checkpointer || checkpointer.trim() === '')
143
+ return 'NONE';
144
+ const lower = checkpointer.toLowerCase();
145
+ // Fast path for known two-word external phrases (e.g. prisma migrate status)
146
+ if (EXTERNAL_PHRASES.some(p => lower.includes(p)))
147
+ return 'EXTERNAL';
148
+ const words = collectCommandWords(checkpointer.toLowerCase());
149
+ if (words.length === 0)
150
+ return 'NONE';
151
+ // Strongest signal wins: EXTERNAL > AGENT_HARNESS > ASSERTION_ONLY
152
+ if (words.some(w => wordMatches(w, EXTERNAL_TOOLS)))
153
+ return 'EXTERNAL';
154
+ if (words.some(w => wordMatches(w, HARNESS_TOOLS)))
155
+ return 'AGENT_HARNESS';
156
+ return 'ASSERTION_ONLY';
157
+ }
158
+ function effectiveReferentKind(args) {
159
+ const { declared, verifierType, checkpointer } = args;
160
+ if (declared === null)
161
+ return 'UNCLASSIFIED';
162
+ if (declared === 'EXTERNAL_FACT' && verifierType === 'MACHINE') {
163
+ if (classifyCheckpointerGrounding(checkpointer) !== 'EXTERNAL') {
164
+ return 'UNVERIFIED_ASSERTION';
165
+ }
166
+ }
167
+ return declared;
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",