@lumoai/cli 1.47.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.
@@ -31,6 +31,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
31
31
  | `task criteria set/list`, drafting the acceptance contract | [references/criteria.md](references/criteria.md) |
32
32
  | `verify`, `task status` — machine verification loop, claim-done flow, self-check/resume | [references/verify.md](references/verify.md) |
33
33
  | `cost` — per-operation (per-tool) token cost read-out; `task lineage` Top-5 | [references/task-context.md](references/task-context.md) |
34
+ | `outcome show/record/rate` — post-hoc outcome well (falsifier oracle, rejection-only) | [references/outcome.md](references/outcome.md) |
34
35
  | `project list`, `milestone*` | [references/milestones.md](references/milestones.md) |
35
36
  | `doc create/update/list/move/bind/share/import` (CRUD) | [references/docs.md](references/docs.md) |
36
37
  | `doc show --raw/--section`, `doc patch/append/diff/rebuild-source` (editing live docs) | [references/doc-editing.md](references/doc-editing.md) |
@@ -92,6 +93,16 @@ The command catalog below is a **map**: it lists every command grouped by domain
92
93
 
93
94
  - `lumo cost [--task <id>|--session <id>|--since <date>] [--by tool|model|member|session] [--json]` — per-operation token cost read-out, attributing each model step's token delta to the tool(s) it ran. Scope is mutually exclusive (default = workspace last-30-days). For the per-task Top-5 inline, see `lumo task lineage`.
94
95
 
96
+ **Criteria audit** — workspace-level referent-kind analytics
97
+
98
+ - `lumo criteria audit` — workspace-level read-out: count of acceptance criteria by effective referent kind (EXTERNAL_FACT / AGENT_CONSTRUCTED_STATE / PENDING_OUTCOME / UNVERIFIED_ASSERTION / UNCLASSIFIED) + the self-confirming-green ratio
99
+
100
+ **Outcome well (post-hoc oracle)** — see [outcome.md](references/outcome.md)
101
+
102
+ - `lumo outcome show <task>` — read the post-hoc outcome well: the **falsifier** verdict (`REJECTED` | `INCONCLUSIVE` — never a "pass") plus its backing rejection signals. `INCONCLUSIVE` = no rejection on record, **not** "satisfied".
103
+ - `lumo outcome record <task> --note "<what reality did>" [--kind <kind>]` — 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. `--note` is required (the observed referent).
104
+ - `lumo outcome rate [--min N] [--json]` — workspace **trust × post-hoc-fate** join (LUM-603): per delivery-time forecast-confidence bracket (high/medium/low), the post-hoc REJECTED rate from the well. Honest by construction — thin brackets read `insufficient` (no fabricated rate), the high-vs-low comparison stays `inconclusive` until CIs separate, and the rate is a **lower bound** (no signal = INCONCLUSIVE, never satisfied). Under-sampled today by design — the skeleton produces real strata once the well fills.
105
+
95
106
  **Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
96
107
 
97
108
  - `lumo task artifact add/update/list/show/rm` — record spec/plan products on a task
@@ -146,6 +157,25 @@ The command catalog below is a **map**: it lists every command grouped by domain
146
157
  - `lumo worktree rm <LUM-N> --yes` — remove a worktree (keeps the branch unless `--delete-branch`)
147
158
  - `lumo worktree list` — list `.worktrees/` worktrees (task id, branch, dirty, node_modules link)
148
159
 
160
+ ## Criteria audit
161
+
162
+ ```bash
163
+ lumo criteria audit
164
+ ```
165
+
166
+ `lumo criteria audit` — workspace-level acceptance-criteria analytics. Calls `GET /api/criteria/audit` and prints two things:
167
+
168
+ 1. **Declared-kind distribution** — count of criteria by effective referent kind:
169
+ - `EXTERNAL_FACT` — declared as externally observable (git SHA, CI status, DB row) and the checkpointer invokes real external tooling
170
+ - `AGENT_CONSTRUCTED_STATE` — passes by checking output the agent itself produced
171
+ - `PENDING_OUTCOME` — deferred to post-hoc falsifier
172
+ - `UNVERIFIED_ASSERTION` — declared `EXTERNAL_FACT` but the checkpointer does **not** actually invoke external tooling (git/CI/HTTP); downgraded by the audit
173
+ - `UNCLASSIFIED` — legacy rows predating the `referentKind` field (null in DB)
174
+
175
+ 2. **Self-confirming-green headline ratio** — `(AGENT_CONSTRUCTED_STATE + UNVERIFIED_ASSERTION) / classified`. Criteria in these two buckets pass by confirming only the agent's own work, not an independent external state.
176
+
177
+ **When to suggest**: when the user wants to audit how much of the workspace's "green" criteria are self-confirming vs externally anchored; when diagnosing whether acceptance criteria have real external hooks or are circular.
178
+
149
179
  ## Commands & flags that do NOT exist (common mistakes)
150
180
 
151
181
  Don't guess these:
@@ -0,0 +1,65 @@
1
+ # Outcome well (post-hoc oracle) — `lumo outcome`
2
+
3
+ The **post-hoc outcome well** (LUM-598) is the single external oracle for correctness + fidelity. It records the real-world fate a delivery met **after** it shipped — facts the agent cannot construct and that occur post-delivery (a revert, a rollback, CI turning red, a reopen, a downstream redirect of the design).
4
+
5
+ ## The one invariant: falsifier, not verifier
6
+
7
+ The well **only** ever produces a rejection. Its verdict space is exactly:
8
+
9
+ - `REJECTED` — the well holds ≥1 rejection signal (reality revoked / redid / bypassed the delivery).
10
+ - `INCONCLUSIVE` — no rejection on record.
11
+
12
+ There is **no `SATISFIED` / `PASS`**. "N sprints and nobody touched it" is the _absence_ of a rejection — satisfied, merely-tolerated, and never-noticed are externally indistinguishable — so it reads `INCONCLUSIVE`, never a pass. Treat `INCONCLUSIVE` as "could not confirm a rejection", not as approval.
13
+
14
+ ## Collection (where signals come from)
15
+
16
+ - **Automatic** — a DONE task reopened (the lineage `REWORKED` transition) is folded into the well as a `REOPENED` signal. No action needed; it happens server-side.
17
+ - **Manual** — a human who observed reality reject a delivery records it with `lumo outcome record`. The `--note` is mandatory: it is the observed referent, frozen as evidence.
18
+
19
+ ## `lumo outcome show <task>`
20
+
21
+ Read the well for a task — the verdict plus its backing signals.
22
+
23
+ ```bash
24
+ lumo outcome show LUM-42
25
+ ```
26
+
27
+ An empty well prints `INCONCLUSIVE` with the reminder that silence is not a pass. A non-empty well prints `REJECTED` and lists each signal (date, kind, source, note).
28
+
29
+ ## `lumo outcome record <task> --note "<what reality did>" [--kind <kind>]`
30
+
31
+ Record one human-observed post-hoc rejection. Append-only — every call inserts a new signal; nothing is ever mutated or cleared.
32
+
33
+ ```bash
34
+ lumo outcome record LUM-42 --note "reverted in #812 after a prod incident" --kind reverted
35
+ lumo outcome record LUM-42 --note "downstream rewrote the API shape this task introduced" --kind downstream_redirect
36
+ ```
37
+
38
+ - `--kind` (case-insensitive, default `manual`): `reverted | rolled_back | ci_regression | downstream_redirect | bypassed | manual`. `reopened` is **not** a manual kind — it is owned by the automatic lineage fold.
39
+ - `--occurred-at <iso>` — when the event actually happened (defaults to now).
40
+
41
+ ## `lumo outcome rate [--min N] [--json]`
42
+
43
+ The workspace-level **trust × post-hoc-fate** join (LUM-603). For every delivery that has a frozen forecast snapshot, it buckets the delivery by its **delivery-time forecast-confidence** (high / medium / low — the only trust axis frozen at delivery, recomputed from the snapshot's `cohortSize` / `tokenCostBasis`) and reports each bracket's post-hoc **REJECTED** rate from the well. The question it answers once data is sufficient: _do high-trust deliveries get rejected by reality less often than low-trust ones?_
44
+
45
+ ```bash
46
+ lumo outcome rate
47
+ lumo outcome rate --min 20 --json
48
+ ```
49
+
50
+ This is the falsifier discipline applied at the **query layer** — it refuses to manufacture a finding off a thin sample:
51
+
52
+ - A bracket with fewer than `--min` (default 10) deliveries reads **`insufficient`** with the rate withheld — the raw `rejected/delivered` counts are still shown, but never divided into a believable proportion.
53
+ - The per-bracket rate is an explicit **lower bound** on true failure: the well only records rejections, so a zero-signal delivery is `INCONCLUSIVE` (counted in the denominator), never "satisfied".
54
+ - The headline **high-vs-low** comparison asserts a direction **only** when both brackets are measured _and_ their 95% confidence intervals are disjoint; otherwise it stays **`inconclusive`**.
55
+
56
+ **Expect `inconclusive` today.** The well is freshly wired and under-sampled — that is the designed state, not a bug. The same command produces real strata once enough post-hoc signals accumulate (the deferred outcome the skeleton exists to read).
57
+
58
+ > Scope note (debt-a): the trust axis is **task-level** forecast confidence only. Fragment-level trust is not frozen at delivery and drifts, so it is deliberately **not** joined here — doing so would read a post-hoc value against a past delivery. Freezing fragment trust is a separate follow-up.
59
+
60
+ ## When to suggest
61
+
62
+ - A teammate says a shipped task **was reverted / rolled back / broke prod / got redone / was bypassed** — record it with `lumo outcome record` so the well carries reality's verdict.
63
+ - Auditing whether a "done" task actually held up — `lumo outcome show <task>` reads the well.
64
+ - Asking whether **higher-confidence deliveries actually fail less** (does delivery-time trust predict post-hoc fate?) — `lumo outcome rate`. Expect `inconclusive` until the well fills; it will never print a correlation the sample can't support.
65
+ - **Never** use it to mark something good: the well has no positive verdict by design. To assert a delivery met its contract, that is the acceptance loop (`lumo verify` / `lumo task status`), not the well.
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatAuditReport = formatAuditReport;
4
+ exports.criteriaAudit = criteriaAudit;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const KIND_ORDER = [
8
+ 'EXTERNAL_FACT',
9
+ 'AGENT_CONSTRUCTED_STATE',
10
+ 'PENDING_OUTCOME',
11
+ 'UNVERIFIED_ASSERTION',
12
+ 'UNCLASSIFIED',
13
+ ];
14
+ function formatAuditReport(r) {
15
+ const lines = ['Criteria referent-kind audit (workspace)', ''];
16
+ for (const k of KIND_ORDER) {
17
+ const n = r.byEffectiveKind[k] ?? 0;
18
+ const pct = r.total === 0 ? 0 : Math.round((n / r.total) * 100);
19
+ lines.push(` ${k.padEnd(24)} ${String(n).padStart(4)} (${pct}%)`);
20
+ }
21
+ lines.push('');
22
+ const pct = Math.round(r.selfConfirmingGreenRatio * 100);
23
+ lines.push(` self-confirming green: ${pct}% of ${r.classified} classified criteria`);
24
+ lines.push(` (AGENT_CONSTRUCTED_STATE + UNVERIFIED_ASSERTION — green that confirms only the agent's own work)`);
25
+ return lines.join('\n') + '\n';
26
+ }
27
+ /** `lumo criteria audit` — workspace-level referent-kind distribution. */
28
+ async function criteriaAudit() {
29
+ const creds = (0, config_1.readCredentials)();
30
+ if (!creds) {
31
+ console.error('Error: not logged in. Run `lumo auth login` first.');
32
+ return 1;
33
+ }
34
+ const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
35
+ let res;
36
+ try {
37
+ res = await fetch(`${base}/api/criteria/audit`, {
38
+ headers: { Authorization: `Bearer ${creds.token}` },
39
+ });
40
+ }
41
+ catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ console.error(`Error: could not reach Lumo API (${msg})`);
44
+ return 1;
45
+ }
46
+ if (!res.ok) {
47
+ console.error(`Error: criteria audit failed (HTTP ${res.status})`);
48
+ return 1;
49
+ }
50
+ const report = (await res.json());
51
+ process.stdout.write(formatAuditReport(report));
52
+ }
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.outcomeRecord = outcomeRecord;
4
+ exports.outcomeShow = outcomeShow;
5
+ exports.outcomeRate = outcomeRate;
6
+ const config_1 = require("../lib/config");
7
+ const api_1 = require("../lib/api");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ /**
10
+ * `lumo outcome` — the post-hoc outcome well (LUM-598).
11
+ *
12
+ * The well is the single external oracle for correctness + fidelity: it records
13
+ * the real-world fate a delivery met AFTER it shipped. It is a FALSIFIER, not a
14
+ * verifier — it only ever reads REJECTED (reality revoked/redid/bypassed the
15
+ * work) or INCONCLUSIVE (no rejection on record). There is deliberately no
16
+ * "mark satisfied": the absence of a rejection is not a pass.
17
+ */
18
+ const MANUAL_KINDS = [
19
+ 'REVERTED',
20
+ 'ROLLED_BACK',
21
+ 'CI_REGRESSION',
22
+ 'DOWNSTREAM_REDIRECT',
23
+ 'BYPASSED',
24
+ 'MANUAL',
25
+ ];
26
+ function authBase() {
27
+ const creds = (0, config_1.readCredentials)();
28
+ if (!creds)
29
+ return { error: 'not logged in. Run `lumo auth login` first.' };
30
+ const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
31
+ const headers = {
32
+ Authorization: `Bearer ${creds.token}`,
33
+ };
34
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
35
+ if (sessionId)
36
+ headers['X-Lumo-Session-Id'] = sessionId;
37
+ return { base, headers };
38
+ }
39
+ /**
40
+ * `lumo outcome record <task> --note "<what reality did>" [--kind <kind>]` —
41
+ * record a human-observed post-hoc REJECTION of a delivery. Append-only. The
42
+ * note is mandatory (the observed referent); the default kind is MANUAL.
43
+ */
44
+ async function outcomeRecord(taskId, options = {}) {
45
+ if (!taskId || taskId.trim() === '') {
46
+ console.error('Error: a task is required: lumo outcome record <task> --note "…"');
47
+ return 1;
48
+ }
49
+ const note = options.note?.trim();
50
+ if (!note) {
51
+ console.error('Error: --note "<what reality did>" is required (the observed rejection, e.g. "reverted in #812 after prod incident").');
52
+ return 1;
53
+ }
54
+ const kind = (options.kind?.trim().toUpperCase() ||
55
+ 'MANUAL');
56
+ if (!MANUAL_KINDS.includes(kind)) {
57
+ console.error(`Error: --kind must be one of: ${MANUAL_KINDS.map(k => k.toLowerCase()).join(', ')}`);
58
+ return 1;
59
+ }
60
+ const auth = authBase();
61
+ if ('error' in auth) {
62
+ console.error(`Error: ${auth.error}`);
63
+ return 1;
64
+ }
65
+ const payload = { kind, note };
66
+ if (options.occurredAt?.trim())
67
+ payload.occurredAt = options.occurredAt.trim();
68
+ let res;
69
+ try {
70
+ res = await fetch(`${auth.base}/api/tasks/${encodeURIComponent(taskId)}/outcome-signals`, {
71
+ method: 'POST',
72
+ headers: { ...auth.headers, 'Content-Type': 'application/json' },
73
+ body: JSON.stringify(payload),
74
+ });
75
+ }
76
+ catch (err) {
77
+ const msg = err instanceof Error ? err.message : String(err);
78
+ console.error(`Error: could not reach Lumo API (${msg})`);
79
+ return 1;
80
+ }
81
+ if (res.status === 401) {
82
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
83
+ return 1;
84
+ }
85
+ if (!res.ok) {
86
+ const errBody = (await res.json().catch(() => null));
87
+ const detail = errBody && typeof errBody.error === 'string'
88
+ ? (0, sanitize_1.sanitizeField)(errBody.error)
89
+ : '';
90
+ console.error(`Error: outcome not recorded (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
91
+ return 1;
92
+ }
93
+ process.stdout.write(`✓ Recorded a ${(0, sanitize_1.sanitizeField)(kind.toLowerCase())} rejection on ${(0, sanitize_1.sanitizeField)(taskId)}.\n` +
94
+ ' The well is a falsifier — this marks the delivery REJECTED by reality; ' +
95
+ 'it is append-only and there is no "satisfied" counterpart.\n');
96
+ return;
97
+ }
98
+ /**
99
+ * `lumo outcome show <task>` — read the well for a task: the falsifier verdict
100
+ * plus its backing rejection signals.
101
+ */
102
+ async function outcomeShow(taskId) {
103
+ if (!taskId || taskId.trim() === '') {
104
+ console.error('Error: a task is required: lumo outcome show <task>');
105
+ return 1;
106
+ }
107
+ const auth = authBase();
108
+ if ('error' in auth) {
109
+ console.error(`Error: ${auth.error}`);
110
+ return 1;
111
+ }
112
+ let res;
113
+ try {
114
+ res = await fetch(`${auth.base}/api/tasks/${encodeURIComponent(taskId)}/outcome-signals`, { headers: auth.headers });
115
+ }
116
+ catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ console.error(`Error: could not reach Lumo API (${msg})`);
119
+ return 1;
120
+ }
121
+ if (res.status === 401) {
122
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
123
+ return 1;
124
+ }
125
+ if (!res.ok) {
126
+ console.error(`Error: could not read the well (HTTP ${res.status}).`);
127
+ return 1;
128
+ }
129
+ const well = (await res.json());
130
+ const mark = well.verdict === 'REJECTED' ? '✗' : '·';
131
+ process.stdout.write(`${mark} Outcome well — ${(0, sanitize_1.sanitizeField)(taskId)}: ${well.verdict}\n`);
132
+ if (well.verdict === 'INCONCLUSIVE') {
133
+ process.stdout.write(' No rejection on record. INCONCLUSIVE ≠ satisfied — the well only ' +
134
+ 'asserts rejection; silence is not a pass.\n');
135
+ return;
136
+ }
137
+ for (const s of well.signals) {
138
+ const when = (s.occurredAt ?? s.detectedAt).slice(0, 10);
139
+ const note = s.evidence?.note ? ` — ${(0, sanitize_1.sanitizeField)(s.evidence.note)}` : '';
140
+ process.stdout.write(` ${when} ${(0, sanitize_1.sanitizeField)(s.kind)} (${(0, sanitize_1.sanitizeField)(s.source)})${note}\n`);
141
+ }
142
+ return;
143
+ }
144
+ /** Render the report client-side (same pattern as `lumo cost` / `outcome show`). */
145
+ function formatRate(report) {
146
+ const lines = [];
147
+ lines.push('Trust × post-hoc fate — delivery-time forecast confidence vs the outcome well');
148
+ lines.push(' (rejection rate is a LOWER BOUND: the well only records rejections; ' +
149
+ 'no signal = INCONCLUSIVE, never "satisfied")');
150
+ lines.push(` ${report.totalDeliveries} snapshotted deliveries · min ${report.minSamples} per bracket`);
151
+ if (report.totalDeliveries === 0) {
152
+ lines.push(' (no delivery has a forecast snapshot yet — nothing to join)');
153
+ return lines.join('\n') + '\n';
154
+ }
155
+ for (const b of report.brackets) {
156
+ if (b.status === 'measured') {
157
+ const pct = ((b.rejectionRate ?? 0) * 100).toFixed(1);
158
+ const lo = ((b.interval?.lower ?? 0) * 100).toFixed(1);
159
+ const hi = ((b.interval?.upper ?? 0) * 100).toFixed(1);
160
+ lines.push(` ${b.bracket.padEnd(6)} ≥${pct}% rejected (${b.rejected}/${b.delivered}, 95% CI ${lo}–${hi}%)`);
161
+ }
162
+ else {
163
+ lines.push(` ${b.bracket.padEnd(6)} insufficient (${b.rejected}/${b.delivered} deliveries < ${report.minSamples} — rate withheld)`);
164
+ }
165
+ }
166
+ const verdict = report.comparison.status === 'measured'
167
+ ? `→ ${(0, sanitize_1.sanitizeField)(report.comparison.reason)}`
168
+ : `→ inconclusive: ${(0, sanitize_1.sanitizeField)(report.comparison.reason)}`;
169
+ lines.push(` ${verdict}`);
170
+ return lines.join('\n') + '\n';
171
+ }
172
+ /**
173
+ * `lumo outcome rate [--min N] [--json]` — read the workspace trust × post-hoc-
174
+ * fate join: per delivery-time forecast-confidence bracket, the post-hoc
175
+ * REJECTED rate from the outcome well. Honest by construction — thin brackets
176
+ * read `insufficient` (no fabricated rate) and the high-vs-low comparison stays
177
+ * `inconclusive` until the well has enough signal to tell the brackets apart.
178
+ */
179
+ async function outcomeRate(options = {}) {
180
+ const auth = authBase();
181
+ if ('error' in auth) {
182
+ console.error(`Error: ${auth.error}`);
183
+ return 1;
184
+ }
185
+ let min;
186
+ if (options.min !== undefined && options.min.trim() !== '') {
187
+ const n = Number(options.min);
188
+ if (!Number.isInteger(n) || n < 1) {
189
+ console.error('Error: --min must be a positive integer');
190
+ return 1;
191
+ }
192
+ min = n;
193
+ }
194
+ const qs = min !== undefined ? `?min=${min}` : '';
195
+ let res;
196
+ try {
197
+ res = await fetch(`${auth.base}/api/outcome/rate${qs}`, {
198
+ headers: auth.headers,
199
+ });
200
+ }
201
+ catch (err) {
202
+ const msg = err instanceof Error ? err.message : String(err);
203
+ console.error(`Error: could not reach Lumo API (${msg})`);
204
+ return 1;
205
+ }
206
+ if (res.status === 401) {
207
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
208
+ return 1;
209
+ }
210
+ if (!res.ok) {
211
+ console.error(`Error: could not read the outcome rate (HTTP ${res.status}).`);
212
+ return 1;
213
+ }
214
+ const report = (await res.json());
215
+ if (options.json) {
216
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
217
+ return;
218
+ }
219
+ process.stdout.write(formatRate(report));
220
+ return;
221
+ }
@@ -5,6 +5,7 @@ exports.taskCriteriaList = taskCriteriaList;
5
5
  const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const sanitize_1 = require("../lib/sanitize");
8
+ const referent_kind_1 = require("../../../shared/src/referent-kind");
8
9
  /**
9
10
  * Render criteria rows for stdout. One line per criterion —
10
11
  * `<id> [TYPE] SOURCE@rN statement` — plus an indented checkpointer line
@@ -16,7 +17,13 @@ function formatCriteriaRows(criteria) {
16
17
  for (const c of criteria) {
17
18
  const provenance = `${c.source}@r${c.addedAtRound}`;
18
19
  const evidence = c.evidenceRequired ? ' [evidence]' : '';
19
- lines.push(`${c.id} [${c.verifierType}] ${provenance}${evidence} ${(0, sanitize_1.sanitizeField)(c.statement)}`);
20
+ const eff = (0, referent_kind_1.effectiveReferentKind)({
21
+ declared: c.referentKind ?? null,
22
+ verifierType: c.verifierType,
23
+ checkpointer: c.checkpointer,
24
+ });
25
+ const kindTag = ` ⟨${eff}⟩`;
26
+ lines.push(`${c.id} [${c.verifierType}] ${provenance}${evidence}${kindTag} ${(0, sanitize_1.sanitizeField)(c.statement)}`);
20
27
  if (c.checkpointer) {
21
28
  lines.push(` ↳ check: ${(0, sanitize_1.sanitizeField)(c.checkpointer)}`);
22
29
  }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyReadback = verifyReadback;
3
4
  exports.taskCriteriaSet = taskCriteriaSet;
4
5
  const config_1 = require("../lib/config");
5
6
  const api_1 = require("../lib/api");
@@ -7,6 +8,31 @@ const doc_input_1 = require("../lib/doc-input");
7
8
  const path_guard_1 = require("../lib/path-guard");
8
9
  const sanitize_1 = require("../lib/sanitize");
9
10
  const task_criteria_list_1 = require("./task-criteria-list");
11
+ /**
12
+ * Landing-integrity read-back (LUM-602): a 200 OK proves absence of error,
13
+ * not presence of correct content. Compare what the server stored (echoed in
14
+ * the PUT response) field-by-field against what we submitted; any divergence
15
+ * means the value was corrupted in transport/landing.
16
+ */
17
+ function verifyReadback(submitted, stored) {
18
+ const issues = [];
19
+ const storedByStatement = new Map(stored.map(c => [c.statement, c]));
20
+ for (const s of submitted) {
21
+ const got = storedByStatement.get(s.statement);
22
+ if (!got) {
23
+ issues.push(`criterion not found after write: "${s.statement.slice(0, 60)}"`);
24
+ continue;
25
+ }
26
+ const wantCk = s.checkpointer ?? null;
27
+ if ((got.checkpointer ?? null) !== wantCk) {
28
+ issues.push(`checkpointer corrupted in landing for "${s.statement.slice(0, 40)}": sent ${JSON.stringify(wantCk)}, stored ${JSON.stringify(got.checkpointer ?? null)}`);
29
+ }
30
+ if (s.referentKind != null && got.referentKind !== s.referentKind) {
31
+ issues.push(`referentKind corrupted in landing for "${s.statement.slice(0, 40)}": sent ${s.referentKind}, stored ${got.referentKind}`);
32
+ }
33
+ }
34
+ return issues;
35
+ }
10
36
  const CAUSE_TAGS = [
11
37
  'NEW_INFO',
12
38
  'SCOPE_CHANGE',
@@ -28,7 +54,7 @@ function parseCriteriaJson(raw) {
28
54
  if (!Array.isArray(parsed) || parsed.length === 0) {
29
55
  return {
30
56
  ok: false,
31
- error: 'expected a non-empty JSON array of criteria, e.g. [{"statement":"…","verifierType":"MACHINE","checkpointer":"npx jest …"}]',
57
+ error: 'expected a non-empty JSON array of criteria, e.g. [{"statement":"…","verifierType":"MACHINE","checkpointer":"npx jest …","referentKind":"AGENT_CONSTRUCTED_STATE"}]',
32
58
  };
33
59
  }
34
60
  return { ok: true, items: parsed };
@@ -171,4 +197,12 @@ async function taskCriteriaSet(identifier, options) {
171
197
  if (data.judgeStepsWarning) {
172
198
  process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.judgeStepsWarning)}\n`);
173
199
  }
200
+ const readbackIssues = verifyReadback(criteriaItems, data.criteria);
201
+ if (readbackIssues.length > 0) {
202
+ for (const issue of readbackIssues) {
203
+ console.error(`⚠ landing-integrity: ${(0, sanitize_1.sanitizeField)(issue)}`);
204
+ }
205
+ console.error('Error: stored contract does not match what was submitted — do not trust the success receipt. Re-run and verify.');
206
+ return 1;
207
+ }
174
208
  }
@@ -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");
@@ -242,6 +244,26 @@ crossing
242
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.')
243
245
  .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
244
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)));
245
267
  program
246
268
  .command('next')
247
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`.')
@@ -256,6 +278,13 @@ program
256
278
  .option('--by <dim>', 'Headline grouping: tool | model | member | session (case-insensitive; default tool)')
257
279
  .option('--json', 'Emit the versioned payload as JSON')
258
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)()));
259
288
  const session = program
260
289
  .command('session')
261
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.47.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",