@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.
@@ -99,18 +99,18 @@ The `Tags:` line is omitted when the resulting tag set is empty.
99
99
 
100
100
  The server's transition matrix (`lib/task/state-machine.ts`):
101
101
 
102
- | From | Allowed targets |
103
- | ----------- | --------------------------------------------------- |
104
- | TODO | IN_PROGRESS, IN_REVIEW, DONE |
105
- | IN_PROGRESS | TODO, IN_REVIEW, DONE |
106
- | IN_REVIEW | TODO, IN_PROGRESS, DONE |
107
- | DONE | TODO, IN_PROGRESS (reopen only — **not** IN_REVIEW) |
102
+ | From | Allowed targets |
103
+ | ----------- | ------------------------------------- |
104
+ | TODO | IN_PROGRESS, IN_REVIEW, DONE |
105
+ | IN_PROGRESS | TODO, IN_REVIEW, DONE |
106
+ | IN_REVIEW | TODO, IN_PROGRESS, DONE |
107
+ | DONE | TODO, IN_PROGRESS, IN_REVIEW (reopen) |
108
108
 
109
109
  Practical rules:
110
110
 
111
- - **One call suffices.** `--status done` straight from TODO or IN_PROGRESS is legal — never walk `in_progress → in_review → done` as a ritual (measured in LUM-392: 70 such chains wasted ~75 calls).
111
+ - **One call suffices.** `--status done` straight from TODO or IN_PROGRESS is legal — never walk `in_progress → in_review → done` as a ritual; it just wastes calls.
112
112
  - **Under the verify flow you don't set `in_review`/`done` at all** — `lumo verify` moves the task to IN_REVIEW on all-pass and the DONE adjudication is human-only.
113
- - **DONE IN_REVIEW is rejected (409).** To attach follow-up context to a DONE task, use `lumo task comment` instead of reopening.
113
+ - **A DONE task can be reopened** — to IN_REVIEW, IN_PROGRESS, or TODO. Reopening is a plain status change and does not alter any recorded acceptance verdict; verdict adjudication is human-only and has no CLI path. To attach context without reopening, `lumo task comment` works.
114
114
 
115
115
  ### When to suggest `task update`
116
116
 
@@ -1,7 +1,6 @@
1
1
  # lumo verify — machine verification loop
2
2
 
3
- `lumo verify` is the machine half of the acceptance system (Acceptance v1,
4
- LUM-343): it executes every **MACHINE** criterion's checkpointer in the local
3
+ `lumo verify` is the machine half of the acceptance system: it executes every **MACHINE** criterion's checkpointer in the local
5
4
  repo, POSTs one structured PASS/FAIL verdict per criterion, and prints what to
6
5
  do next. Execution is on the client; adjudication is server-side — round
7
6
  numbering, the **3-round cap**, escalation, and the **IN_REVIEW** transition all
@@ -14,11 +13,22 @@ touching its status — run `lumo verify`.** The loop replaces "I read the code
14
13
  and it looks done" with executed evidence.
15
14
 
16
15
  ```bash
17
- lumo verify # session-bound task
18
- lumo verify LUM-42 # explicit task (overrides the session binding)
19
- lumo verify --timeout 900 # per-checkpointer timeout in seconds (default 600)
16
+ lumo verify --note "implemented X in foo.ts because Y; tests + tsc pass" # session-bound task
17
+ lumo verify LUM-42 --note "…" # explicit task (overrides the session binding)
18
+ lumo verify --note "…" --timeout 900 # per-checkpointer timeout in seconds (default 600)
20
19
  ```
21
20
 
21
+ **`--note` is required (LUM-597)** whenever a round will actually be posted (the
22
+ task has MACHINE criteria). It is your one-line self-report — what you did and
23
+ why it is ready ("我改了 X,因为 Y 可验收"). When the round all-passes and the
24
+ task flips to IN_REVIEW, the note is frozen as the task's **claim** (provenance
25
+ `AGENT` — the汇报者's own voice, not a summarizer paraphrase) and is checked
26
+ against the diff for faithfulness. Capturing the claim is deterministic ("不填不
27
+ 让 post"); whether it is _truthful_ is the faithfulness audit's job — so don't
28
+ under- or over-state it. A missing/blank note is refused **before** the round is
29
+ posted, so no round is burned; just re-run with one. (An old CLI that omits it
30
+ still verifies — the claim degrades to the synthesized run-summary fallback.)
31
+
22
32
  ## What one round does
23
33
 
24
34
  1. Loads the task's acceptance contract and picks out MACHINE criteria.
@@ -29,11 +39,11 @@ lumo verify --timeout 900 # per-checkpointer timeout in seconds (default 600)
29
39
  TaskActivity event.
30
40
  4. Prints the round outcome:
31
41
 
32
- | Round outcome | Effect | What to do |
33
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
34
- | **All PASS** | Task transitions to **IN_REVIEW** (existing state machine + TASK_IN_REVIEW notification) | **Stop here.** Human adjudication + any HUMAN criteria take over; **never set DONE yourself** |
35
- | **Any FAIL** | Task status untouched; unmet criteria printed as next actions (statement, checkpointer, failure tail) | Fix and re-run |
36
- | **Round 3 still failing** | Loop escalates: a human is notified (AGENT_VERIFY, requires action); further `lumo verify` rounds are rejected with **409** | **Stop retrying**; fix only what the human directs |
42
+ | Round outcome | Effect | What to do |
43
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
44
+ | **All PASS** | Task transitions to **IN_REVIEW** (existing state machine + TASK_IN_REVIEW notification); the `--note` self-report is frozen as the task's claim (source `AGENT`) | **Stop here.** Human adjudication + any HUMAN criteria take over; **never set DONE yourself** |
45
+ | **Any FAIL** | Task status untouched; unmet criteria printed as next actions (statement, checkpointer, failure tail) | Fix and re-run |
46
+ | **Round 3 still failing** | Loop escalates: a human is notified (AGENT_VERIFY, requires action); further `lumo verify` rounds are rejected with **409** | **Stop retrying**; fix only what the human directs |
37
47
 
38
48
  Exit code 0 = all passed (or nothing to run); 1 = failures, escalation, or
39
49
  errors.
@@ -57,7 +67,7 @@ errors.
57
67
  | **HUMAN-only contract** (zero MACHINE criteria) | Nothing to run; CLI says so and suggests `lumo task update <id> --status in_review` for human review. No server write happens. |
58
68
  | **Partial round** | A round must cover every MACHINE criterion; the CLI always runs all of them and the server rejects partial rounds. |
59
69
  | **`REVIEW_ADDED` criteria** | Criteria added during review appear in the contract and are picked up automatically by the next round. |
60
- | **Session bound to a different task** (LUM-459) | Server returns 409, surfaced as an error. No advisory printed; the verify round is rejected outright. |
70
+ | **Session bound to a different task** | Server returns 409, surfaced as an error. No advisory printed; the verify round is rejected outright. |
61
71
  | **Provably-unbound session** | Response carries `bindingAdvisory: 'unbound'`; prints `⚠ Working unbound — this verify ran from a Claude Code session not attached to the task.` Recorded as a `SESSION_BINDING_MISSING` boundary crossing (visible in `lumo task status` open crossings). Run `lumo session attach <LUM-N>` before the next verify. |
62
72
  | **Unconfirmed session binding** | `bindingAdvisory: 'unconfirmed'` → softer advisory `⚠ Could not confirm this session is attached to the task.` Same remediation: `lumo session attach <LUM-N>`. |
63
73
 
@@ -85,8 +95,7 @@ clauses that can never be found wanting.
85
95
 
86
96
  ## lumo task status — the read half (self-check entry point)
87
97
 
88
- `lumo task status [task] [--json]` is the read-only counterpart of the loop
89
- (LUM-344): pure read, milliseconds, no LLM, never writes — running it costs
98
+ `lumo task status [task] [--json]` is the read-only counterpart of the loop: pure read, milliseconds, no LLM, never writes — running it costs
90
99
  nothing and burns no round. Defaults to the session-bound task; an explicit
91
100
  identifier overrides.
92
101
 
@@ -111,34 +120,35 @@ what's unmet and why (the exact failure tails), and how many rounds are left.
111
120
  ### What it prints
112
121
 
113
122
  - **Header** — task identifier/title/status + `verification round N/3` (round 0 = never verified) + an escalation warning when the machine loop is exhausted.
114
- - **Claim vs verification** (LUM-564) — 规律 2 声称vs核验: the headline contrast, printed right after the header (whenever the contract exists), so the report shows **both** columns instead of only the verification one. Two sides:
115
- - **Claim** — what the agent _says_ it did: the latest session's run summary, **same-source** with the web delivery card's `latestRunSummary` (the LLM run summary, else the raw STOP turn digest). Explicitly tagged an **unverified self-report** (`agent self-report · estimated, not verification`) — estimate-tier provenance (估, not 测), with a `↳ source:` line distinguishing an `LLM run summary` from a `raw turn digest no LLM summary yet`. **Fail-closed**: with no run summary it prints `not generated yet — the agent run summary is synthesized when the task reaches DONE`, never a fabricated claim.
116
- - **Verification** — what was actually _confirmed_ (measured): the machine-verification rollup (LUM-470/456) `N machine-verified / M human override (of T MACHINE criteria)` over the active MACHINE criteria (relocated here from its old standalone line under `Criteria`), plus `X of Y criteria met by their latest verdict`. **Fail-closed**: before any round runs it prints `no verification has run yet — the claim is unconfirmed` rather than implying a pass.
117
- - Carried in `--json` as `claim { text, source }` (`source: 'RUN_SUMMARY' | 'DIGEST' | null`; `text: null` = none generated). Omitted only against an older server that doesn't emit the field. The machine-verification rollup is still carried top-level as `machineVerification`.
123
+ - **Claim vs verification** — 规律 2 声称vs核验: the headline contrast, printed right after the header (whenever the contract exists), so the report shows **both** columns instead of only the verification one. Two sides:
124
+ - **Claim** — what the agent _says_ it did: an **unverified self-report** (`agent self-report · estimated, not verification`), estimate-tier provenance (估, not 测). Sourced by layering (LUM-597, preference `AGENT > RUN_SUMMARY > DIGEST > null`): if you supplied a `lumo verify --note`, that **own self-report** is the claim (`↳ source: agent self-report (verify --note)`) — the汇报者's voice, what faithfulness judges. Absent a self-report (old CLI / non-verify path) it degrades to the summarizer paraphrase, labelled honestly (`↳ source: synthesized run summary (no self-report)`). **Fail-closed**: when only a raw STOP turn digest exists it prints `generating — the formal run summary is still being synthesized` and **withholds the raw digest**; with no material at all it prints `not generated yet — …`, never a fabricated claim.
125
+ - **Verification** — what was actually _confirmed_ (measured): the machine-verification rollup `N machine-verified / M human override (of T MACHINE criteria)` over the active MACHINE criteria (relocated here from its old standalone line under `Criteria`), plus `X of Y criteria met by their latest verdict`. **Fail-closed**: before any round runs it prints `no verification has run yet — the claim is unconfirmed` rather than implying a pass.
126
+ - **Faithfulness** the third state over the two columns: whether the CLAIM itself is _true_, read from the persisted faithfulness verdict (no LLM at read time), printed as `▸ Faithfulness — does the claim match the delivery`. One of `faithful` / `overstated` (claim says more than the diff/PR shows) / `under-reported` (claim says less) / `unjudgeable`, with an `· evidence: PR #N · <sha>` pointer when the judge cited one. **Fail-closed**: `not yet judged` (no verdict — distinct from a real `unjudgeable` verdict) and `… (stale — re-checked on the next batch)` (judged against an older claim snapshot, the verdict still shown) are surfaced explicitly. Omitted only against an older server that doesn't emit it.
127
+ - Carried in `--json` as `claim { text, source }` (`source: 'AGENT' | 'RUN_SUMMARY' | 'DIGEST' | null`; `AGENT` = the verify self-report, `RUN_SUMMARY`/`DIGEST` = the synthesized fallback; `text: null` = none generated) and `faithfulness { state, verdict, evidence, diffShas, prNumbers, judgedAt, judgedClaimAt }` (`state` adds `PENDING`/`STALE` over the four verdicts). Omitted only against an older server that doesn't emit the field. The machine-verification rollup is still carried top-level as `machineVerification`.
118
128
  - **Criteria** — every criterion as `<glyph> <id> [TYPE] SOURCE@rN statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with its checkpointer and latest verdict line (failure tail on fail). `REVIEW_ADDED@rN` provenance is visible per row.
119
- - A passing **MACHINE** criterion's verdict line carries a machine-state tag derived from the read model's `machinePassed` flag, NOT the latest verdict (LUM-470): `· machine-verified` when a checkpointer actually passed it (even after a human later signs the task off), or `· human override (no machine pass)` when it passes only on a human sign-off with no machine run underneath. This keeps the terminal honest with web — a machine-verified criterion that a human co-signed no longer reads as a plain human pass.
120
- - A verdict's **evidence is drillable** (LUM-563), rendered as an indented `↳ evidence:` line under the verdict (PASS _and_ FAIL) instead of the inert raw pointer that used to ride the verdict line — so a conclusion points at real proof you can act on, not just the `check:` command: a `cmd:` pointer prints the actual command + exit code (`ran \`…\` → exit N · re-run to reproduce`), a `file:`pointer prints a terminal-clickable`path:line`, and a `commit:` pointer prints a navigable web URL (`<repo>/commit/<hash>`, resolved from the local git `origin`remote) or a`git show <hash>`fallback when no remote resolves. A criterion that **requires evidence but has none recorded yet** (e.g. a HUMAN evidence criterion before sign-off) renders an explicit`↳ evidence: pending — no reference recorded yet`(fail-closed) instead of a bare, dead`[evidence]` tag.
121
- - A pass can carry a **`⚠ pre-edit version`** note (LUM-457): the criterion was changed after that verdict (reworded, or its checkpointer was swapped so the recorded evidence ran a different command). The pass still counts as met (a stale pass does not block DONE — render-only signal), but it vouches for an older version — **re-run `lumo verify` to re-confirm against the current criterion.** This is the habit whenever you edit a MACHINE criterion's checkpointer mid-task: change the check, then re-verify so the green is honest.
129
+ - A passing **MACHINE** criterion's verdict line carries a machine-state tag derived from the read model's `machinePassed` flag, NOT the latest verdict: `· machine-verified` when a checkpointer actually passed it (even after a human later signs the task off), or `· human override (no machine pass)` when it passes only on a human sign-off with no machine run underneath. This keeps the terminal honest with web — a machine-verified criterion that a human co-signed no longer reads as a plain human pass.
130
+ - A verdict's **evidence is drillable**, rendered as an indented `↳ evidence:` line under the verdict (PASS _and_ FAIL) instead of the inert raw pointer that used to ride the verdict line — so a conclusion points at real proof you can act on, not just the `check:` command: a `cmd:` pointer prints the actual command + exit code (`ran \`…\` → exit N · re-run to reproduce`), a `file:`pointer prints a terminal-clickable`path:line`, and a `commit:` pointer prints a navigable web URL (`<repo>/commit/<hash>`, resolved from the local git `origin`remote) or a`git show <hash>`fallback when no remote resolves. A criterion that **requires evidence but has none recorded yet** (e.g. a HUMAN evidence criterion before sign-off) renders an explicit`↳ evidence: pending — no reference recorded yet`(fail-closed) instead of a bare, dead`[evidence]` tag.
131
+ - A pass can carry a **`⚠ pre-edit version`** note: the criterion was changed after that verdict (reworded, or its checkpointer was swapped so the recorded evidence ran a different command). The pass still counts as met (a stale pass does not block DONE — render-only signal), but it vouches for an older version — **re-run `lumo verify` to re-confirm against the current criterion.** This is the habit whenever you edit a MACHINE criterion's checkpointer mid-task: change the check, then re-verify so the green is honest.
122
132
  - **History** — one line per recorded round: `rN · timestamp · X PASS / Y FAIL`.
123
133
  - **Last round failures** — the most recent round's FAIL verdicts with their rejection reasons (why the last round bounced).
124
- - **Cost** (LUM-560) — 规律 1: the costs a human should weigh, on the same report as the verdict instead of scattered across the web delivery card and `task lineage`. Three lines: **Tokens** (total input+output+cache across the task's sessions), **Active time** (non-idle agent seconds — Σ per-turn `STOP − prompt`, LUM-487), and **Rework rounds** (verify rounds that recorded a FAIL). Read from the **same** server-side source the web delivery card consumes (`retrospectiveRepository.loadActuals`), so the two reports cannot drift. Token cost is **fail-closed**: when no session usage was recorded it prints `Tokens: not recorded (no session usage captured)`, kept distinct from a measured `0` (没测到 vs 花了0, aligned with LUM-559). Carried in `--json` as `cost { tokenCost, activeTimeSec, reworkRounds }` (`tokenCost: null` = not measured). Omitted only against an older server that doesn't emit the field.
125
- - **Struggle / rework / outstanding** (LUM-561) — the anti-mum-and-deaf block: **always printed when the contract exists, even on a clean 0-unmet task** so a passing task still shows its scars instead of wiping them to a single PASS count. Lists, when present:
134
+ - **Cost** — 规律 1: the costs a human should weigh, on the same report as the verdict instead of scattered across the web delivery card and `task lineage`. Three lines: **Tokens** (total input+output+cache across the task's sessions), **Active time** (non-idle agent seconds — Σ per-turn `STOP − prompt`), and **Rework rounds** (verify rounds that recorded a FAIL). Read from the **same** server-side source the web delivery card consumes (`retrospectiveRepository.loadActuals`), so the two reports cannot drift. Token cost is **fail-closed**: when no session usage was recorded it prints `Tokens: not recorded (no session usage captured)`, kept distinct from a measured `0` (没测到 vs 花了0). Carried in `--json` as `cost { tokenCost, activeTimeSec, reworkRounds }` (`tokenCost: null` = not measured). Omitted only against an older server that doesn't emit the field.
135
+ - **Struggle / rework / outstanding** — the anti-mum-and-deaf block: **always printed when the contract exists, even on a clean 0-unmet task** so a passing task still shows its scars instead of wiping them to a single PASS count. Lists, when present:
126
136
  - **rework rounds** — verify rounds that had a FAIL;
127
137
  - **send-backs** — criteria sent back by a human/agent verdict (a MACHINE verify-loop FAIL is not a 打回), with their open/resolved lifecycle, preserved even for since-removed criteria;
128
138
  - **leftover follow-ups** — criteria whose latest verdict is `PASS_WITH_FOLLOWUP`;
129
- - **PR iterations** — when the task has >1 PR (the dominant rework signal when the verify loop ran once but the work churned across many follow-up PRs — e.g. LUM-557: ~10 PRs vs 1 verify round); a single PR is the happy path and is not flagged;
139
+ - **PR iterations** — when the task has >1 PR (the dominant rework signal when the verify loop ran once but the work churned across many follow-up PRs); a single PR is the happy path and is not flagged;
130
140
  - **reopens** — backward `IN_REVIEW/DONE → IN_PROGRESS/TODO` transitions (from the `STATUS_CHANGED` log): the task reached review/done and got bounced, a rework that leaves no FAIL verdict.
131
141
 
132
142
  When the trail is genuinely empty it states the **basis** (`None recorded — N rounds run, 0 FAIL, no send-backs, no reopens, no leftover follow-ups`); when nothing has been verified yet it says so (`No verification has run yet — cannot confirm there were no difficulties`) rather than rendering an implicitly-clean slate. Carried in `--json` as `struggleTrail` (incl. `pullRequests` + `reopens`).
133
143
 
134
- - **Trend** (LUM-562) — 规律 7 趋势非快照: the _movement_ of the key quantities across the task's attempts, not a single snapshot. Where History/Cost/Struggle list current values, this shows direction: **Pass rate** across verification rounds (`r1 60% → r2 100% (↑ +40pts)`), **Cost/session** across the task's sessions (`4.2K → 1.1K tokens (↓), 5.3K total` — per-session spend from the same source as the **Cost** total, so the trajectory's points sum to it), and **Rework** accrual (`3 accrued — 1 FAIL round, 1 reopen, +1 PR cycle (↑ from 0)`). **Honest about a single point:** with only one round and one session every quantity is one data point, so it prints `Single attempt so far — no trajectory yet (a trend needs ≥2 rounds or sessions)` rather than drawing a fake arrow off one value (closes the named "1-round pass = single point" gap). When nothing was verified and no cost was measured it says `No verification rounds or measured cost yet — nothing to trend`. Carried in `--json` as `trend { passRate[], cost[], rework{} }`. Omitted only against an older server.
144
+ - **Trend** — 规律 7 趋势非快照: the _movement_ of the key quantities across the task's attempts, not a single snapshot. Where History/Cost/Struggle list current values, this shows direction: **Pass rate** across verification rounds (`r1 60% → r2 100% (↑ +40pts)`), **Cost/session** across the task's sessions (`4.2K → 1.1K tokens (↓), 5.3K total` — per-session spend from the same source as the **Cost** total, so the trajectory's points sum to it), and **Rework** accrual (`3 accrued — 1 FAIL round, 1 reopen, +1 PR cycle (↑ from 0)`). **Honest about a single point:** with only one round and one session every quantity is one data point, so it prints `Single attempt so far — no trajectory yet (a trend needs ≥2 rounds or sessions)` rather than drawing a fake arrow off one value. When nothing was verified and no cost was measured it says `No verification rounds or measured cost yet — nothing to trend`. Carried in `--json` as `trend { passRate[], cost[], rework{} }`. Omitted only against an older server.
135
145
 
136
146
  - **Next actions** — the unmet criteria (latest verdict is not a pass: failed or never verified, HUMAN ones included). This list IS the plan — recomputed from the event log on every read, never maintained separately. Empty + rounds recorded = awaiting human adjudication.
137
- - **Open boundary crossings** (LUM-448) — a trailing safety block when the task has ≥1 OPEN (undispositioned) forbidden-action crossing: a count, then one line per crossing `• [SEVERITY] CATEGORY — <clipped detail>` (highest-severity first), each followed by a read-only **attribution** line `↳ by model=<m> · agent=<type>[/branch] · session=<8-char prefix>` (LUM-469 — who/what crossed; any dimension that couldn't be resolved server-side prints `unknown`, never a fabricated value), then a pointer to the web acceptance panel. Silent when there are none, so it never overshadows the criteria.
138
- - **Read-only awareness** — this surfaces crossings detected elsewhere (LUM-426/435/442); there is no CLI path to disposition or clear one. Disposition stays web + human-only (LUM-426/435/422): an agent/CLI bearer cannot clear its own crossing from the terminal.
139
- - **The check fails closed (LUM-480):** if the crossings read itself errors (network / server / parse), the block prints `⚠ Boundary-crossing check failed (network/server error) — could not confirm whether any are undispositioned` instead of staying silent. Silence means a successful read with zero open crossings, never a failed check — a hiccup can no longer masquerade as "all clear".
147
+ - **Open boundary crossings** — a trailing safety block when the task has ≥1 OPEN (undispositioned) forbidden-action crossing: a count, then one line per crossing `• [SEVERITY] CATEGORY — <clipped detail>` (highest-severity first), each followed by a read-only **attribution** line `↳ by model=<m> · agent=<type>[/branch] · session=<8-char prefix>` (who/what crossed; any dimension that couldn't be resolved server-side prints `unknown`, never a fabricated value), then a pointer to the web acceptance panel. Silent when there are none, so it never overshadows the criteria.
148
+ - **Read-only awareness** — this surfaces crossings detected elsewhere; there is no CLI path to disposition or clear one. Disposition stays web + human-only: an agent/CLI bearer cannot clear its own crossing from the terminal.
149
+ - **The check fails closed:** if the crossings read itself errors (network / server / parse), the block prints `⚠ Boundary-crossing check failed (network/server error) — could not confirm whether any are undispositioned` instead of staying silent. Silence means a successful read with zero open crossings, never a failed check — a hiccup can no longer masquerade as "all clear".
140
150
 
141
- ### Responding to an open crossing — `lumo crossing explain` (LUM-542)
151
+ ### Responding to an open crossing — `lumo crossing explain`
142
152
 
143
153
  When `lumo task status` surfaces an OPEN crossing you believe is a false positive
144
154
  — or you simply want to leave a rationale for the human reviewer — append a
@@ -151,7 +161,7 @@ lumo crossing explain <id> --note "this was a generated fixture, not a hand-edit
151
161
  This is the **inverse** of dispositioning, but it is the agent/CLI path
152
162
  (bearer-only; a clerk/human caller is refused). Behavior:
153
163
 
154
- - it can **only append** an append-only note — it **never clears the crossing or unblocks Done** (disposition stays web + human-only, LUM-448);
164
+ - it can **only append** an append-only note — it **never clears the crossing or unblocks Done** (disposition stays web + human-only);
155
165
  - the note is shown to the human reviewer at disposition time, kept for later review, and explicitly labeled _agent self-report · unverified_;
156
166
  - `<id>` must be a crossing on the **session-bound task** (resolved from `$CLAUDE_CODE_SESSION_ID`; cross-task targets and unbound/mismatched sessions are rejected);
157
167
  - earlier explanations are immutable — a correction is a new note.
@@ -162,16 +172,16 @@ This is the **inverse** of dispositioning, but it is the agent/CLI path
162
172
  `1`). The schema is versioned: breaking shape changes bump the major; additive
163
173
  fields don't. Pin on `version` when scripting against it.
164
174
 
165
- - each criterion carries `machinePassed` (boolean — a checkpointer currently vouches for it; LUM-456/470);
175
+ - each criterion carries `machinePassed` (boolean — a checkpointer currently vouches for it);
166
176
  - the payload carries a top-level `machineVerification` aggregate `{ total, machineVerified, humanOverridden }` over the active MACHINE criteria — read these, not `latestVerdict` alone, to tell a machine-verified criterion from a human override;
167
- - open boundary crossings ride along as an additive top-level `openCrossings`, each entry `{ id, category, severity, detail, attribution }` where `attribution` is `{ workspaceMemberId, sessionId, agent, worktreeBranch, model }` with every field nullable — null = unknown, never fabricated (LUM-469); the array length is the count. Same read-only awareness, no write path;
168
- - **`openCrossings` is `null` when the crossings check failed (LUM-480)** — distinct from `[]` (a successful read with zero open crossings). Script consumers must treat `null` as "unknown / could not confirm", **not** "safe".
177
+ - open boundary crossings ride along as an additive top-level `openCrossings`, each entry `{ id, category, severity, detail, attribution }` where `attribution` is `{ workspaceMemberId, sessionId, agent, worktreeBranch, model }` with every field nullable — null = unknown, never fabricated; the array length is the count. Same read-only awareness, no write path;
178
+ - **`openCrossings` is `null` when the crossings check failed** — distinct from `[]` (a successful read with zero open crossings). Script consumers must treat `null` as "unknown / could not confirm", **not** "safe".
169
179
 
170
180
  `status` reads; `verify` judges. Running status never starts a round, never
171
181
  escalates, and never changes task state — loop rules (cap 3, IN_REVIEW on
172
182
  all-pass, human-only DONE) live entirely in `lumo verify` and the server.
173
183
 
174
- ## lumo verdict — the three verdict channels (LUM-422)
184
+ ## lumo verdict — the three verdict channels
175
185
 
176
186
  `lumo verify` is the MACHINE channel. `lumo verdict` covers the other two — the
177
187
  HUMAN pass and the AGENT send-back — under one red line: **no passing data row is
@@ -36,7 +36,7 @@ Errors if the target dir already exists; reuses the branch if it already exists
36
36
 
37
37
  - **`prisma generate` clobbers all worktrees.** The generated client lives in the shared (symlinked) `node_modules`, so a `generate` in one worktree overwrites the client every parallel worktree depends on. Verify with jest (SWC mocks Prisma); do `generate + tsc` atomically once at the end.
38
38
  - **Run jest from the worktree root** (`cd` in first). `cli/` has no jest config; running from the main checkout hits the `cli/package.json` haste collision and silently runs the wrong tests.
39
- - **Husky hooks are copied in for you.** Husky owns hooks via `core.hooksPath = .husky/_`, resolved relative to each worktree's root. That `_` shim is **untracked** (regenerated on the main checkout's `npm install`/`prepare`), so a fresh worktree would lack it and git would **silently skip every hook** — pre-commit (lint-staged) and commit-msg (LUM-405 drift-check). With no GitHub CI, husky is the only deterministic quality gate, so `add` copies `.husky/_` in (copy, not symlink). If the main checkout has no `.husky/_`, `add` warns you to `npm install` there rather than skipping silently.
39
+ - **Husky hooks are copied in for you.** Husky owns hooks via `core.hooksPath = .husky/_`, resolved relative to each worktree's root. That `_` shim is **untracked** (regenerated on the main checkout's `npm install`/`prepare`), so a fresh worktree would lack it and git would **silently skip every hook** — pre-commit (lint-staged) and commit-msg (drift-check). With no GitHub CI, husky is the only deterministic quality gate, so `add` copies `.husky/_` in (copy, not symlink). If the main checkout has no `.husky/_`, `add` warns you to `npm install` there rather than skipping silently.
40
40
  - **Never `npm install` / `npm ci` inside a worktree.** npm doesn't respect the `node_modules` symlink — it deletes it and reifies a full standalone tree (~1 min, shared prisma-client gone). Install only in the main checkout, then re-create the symlink if npm replaced it. (Older npm could plant a self-referential `node_modules/node_modules` that hard-panics Turbopack's `next build`; the `prebuild`/`predev`/`preanalyze` guard `scripts/fix-nodemodules-selflink.ts` removes it, but this rule avoids the mess.)
41
41
 
42
42
  ## `lumo worktree rm <LUM-N>`
@@ -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
  }