@lumoai/cli 1.59.0 → 1.60.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.
@@ -108,8 +108,9 @@ The command catalog below is a **map**: it lists every command grouped by domain
108
108
  ### Verification (machine acceptance loop) — see [verify.md](references/verify.md)
109
109
 
110
110
  - `lumo verify [task] [--note "<self-report>"] [--timeout <seconds>]` — run every MACHINE criterion's checkpointer locally and report a structured PASS/FAIL verdict per criterion. `--note` is an optional one-line self-report ("我改了 X,因为 Y 可验收"), frozen as the task's claim (source AGENT) when the round passes into IN_REVIEW. The round cap is the workspace's `verificationMaxRounds` (default 3) — read the actual budget off the `Round N/M` line the command prints, never assume a number. All-pass → task to IN_REVIEW (agent stops); a fail on the last round → escalate to human. **Run this before claiming a task is done.**
111
- - `lumo task status [task] [--full] [--json]` — read-only acceptance self-check (no LLM): the contract with each criterion's latest verdict, the current round, last failure reasons, `nextActions` (the unmet criteria, plus any undispositioned PR **security findings** as `SECURITY_FINDING` entries — `blocks DONE` vs `advisory`), a per-linked-PR **scan status line** (stage states incl. the L3 `hunt` layer, external-finding count, partial flag, external failure reason — LUM-735/739), and any OPEN boundary crossings; `--full` adds the dashboard sections (verification rollup, history, cost, struggle trail, trend) (fails closed — `null`/`⚠` means "could not confirm", not safe). **Run it first when resuming a task or after a round was rejected.**
112
- - `lumo crossing explain <id> --note "<text>"` — append an agent self-explanation ("申辩") to a boundary crossing; append-only, **never clears the crossing or unblocks Done** (disposition stays web + human-only). A review aid, not a self-clear.
111
+ - `lumo task status [task] [--full] [--json]` — read-only acceptance self-check (no LLM): the contract with each criterion's latest verdict, the current round, last failure reasons, `nextActions` (the unmet criteria, plus any undispositioned PR **security findings** as `SECURITY_FINDING` entries — `blocks DONE` vs `advisory`), a per-linked-PR **scan status line** (stage states incl. the L3 `hunt` layer, external-finding count, partial flag, external failure reason — LUM-735/739) plus the hunt's per-category coverage line (LUM-762), and any OPEN boundary crossings; `--full` adds the dashboard sections (verification rollup, history, cost, struggle trail, trend) (fails closed — `null`/`⚠` means "could not confirm", not safe). **Run it first when resuming a task or after a round was rejected.**
112
+ - `lumo crossing explain <id> --note "<text>"` — append an agent self-explanation ("申辩") to a boundary crossing; append-only, **never clears the crossing or unblocks Done**. A review aid, not a self-clear.
113
+ - `lumo crossing disposition <id> --false-positive | --confirmed [--note "<text>"] [--task <LUM-N>]` — record the **user's** ruling on a task crossing through a **three-step** confirmation handshake the server enforces (LUM-769): (1) no step flag — prints the crossing (severity, detail, recurrence, your explanations) as an exit-4 envelope with a stage-1 **read receipt**, writes nothing; relay it and ask the user to confirm they have **read** it, then run its `confirmCommand` (`--receipt <r1> --confirm-read`); (2) that prints a second exit-4 envelope for the ruling with the stage-2 receipt; relay it and ask the user to **approve**, then run its `confirmCommand` (`--receipt <r2> --confirm`); (3) the write. Confirming the ruling before the read is acknowledged is refused and told to confirm the read first; never supply either step flag yourself. Either ruling clears that crossing's block on DONE and is audited as a CLI-channel disposition. Reverting to OPEN, repository suppression rules and steward (milestone) crossings stay web-only. See [verify.md](references/verify.md)
113
114
 
114
115
  ### Cost (per-operation token read-out) — see [task-context.md](references/task-context.md)
115
116
 
@@ -235,5 +236,5 @@ Don't guess these:
235
236
  - **Evidence before "done".** Never claim done from reading code — run `lumo verify`. While a PR is open, the task is IN_REVIEW, not DONE.
236
237
  - **Never set DONE yourself after a verify loop** — that adjudication is human-only. An unresolved send-back blocks the DONE transition with 409.
237
238
  - **Status updates are one direct call** (`task update --status done`), not a step-by-step walk through `in_progress → in_review → done`.
238
- - **Exit code 4 = the user must confirm — you may not.** A gated mutation (`--status done`, `sprint/milestone/doc delete`, `memory rm`, `task deps/artifact rm`, `sprint close --move-all/--backlog-all`, `worktree rm`, `doc rebuild-source` on an existing source) run without `--confirm` prints a `confirmation_required` envelope on stdout and does nothing. Show every line of its `changes` to the user, wait for their explicit approval, then run its `confirmCommand` verbatim. **Never add `--confirm` (or `--yes` / `--force`) yourself** — the flag means "the user has confirmed", and adding it on your own initiative is the exact failure the protocol exists to stop. **Exit code 5 = blocked by a human-only gate** (`DONE_BLOCKED`: a send-back, an undispositioned boundary crossing, a blocking security finding): relay its `blockers` and `remediation` to the user; nothing on the CLI clears it — no retry, no flag, no rewording a failed criterion. Details: [confirmation.md](references/confirmation.md)
239
+ - **Exit code 4 = the user must confirm — you may not.** A gated mutation (`--status done`, `sprint/milestone/doc delete`, `memory rm`, `task deps/artifact rm`, `sprint close --move-all/--backlog-all`, `worktree rm`, `doc rebuild-source` on an existing source) run without `--confirm` prints a `confirmation_required` envelope on stdout and does nothing. Show every line of its `changes` to the user, wait for their explicit approval, then run its `confirmCommand` verbatim. **Never add `--confirm` (or `--yes` / `--force`) yourself** — the flag means "the user has confirmed", and adding it on your own initiative is the exact failure the protocol exists to stop. **Exit code 5 = blocked by a human-only gate** (`DONE_BLOCKED`: a send-back, an undispositioned boundary crossing, a blocking security finding): relay its `blockers` and `remediation` to the user; nothing on `task update` clears it — no retry, no flag, no rewording a failed criterion. A boundary crossing is the one blocker with a terminal path, and it is the user's: `lumo crossing disposition <id> --false-positive | --confirmed` prints an exit-4 envelope you relay exactly like `--status done` — never self-approve. Details: [confirmation.md](references/confirmation.md)
239
240
  - **Read the reference before composing a command** whose flags/edge-cases matter — don't run from memory.
@@ -50,25 +50,27 @@ Nothing goes to stderr; the envelope is the command's result, not an error. **Ex
50
50
  - `--confirm` — the user approved. Present on every command in the table below.
51
51
  - `--yes` — legacy alias of `--confirm` on the commands that took it before LUM-755. Prefer `--confirm` in new invocations. `task update` never had `--yes` and does not gain it.
52
52
  - `--force` — a **second gate** that stays separate from `--confirm`: `worktree rm` uses it to acknowledge discarding uncommitted changes; `doc rebuild-source` uses it to acknowledge replacing an existing source. Missing `--force` also yields an envelope (exit 4) whose `confirmCommand` carries `--force --confirm`.
53
+ - `--confirm-read` + `--receipt <token>` — the **read-acknowledgement handshake** (LUM-769) on `crossing disposition`, a three-step protocol the _server_ enforces, the way a web form is loaded, "I have read this" is ticked, and only then submit works. **Step 1** (no step flag) reads the crossing and obtains a stage-1 receipt bound to the crossing, the ruling, your session/member and the crossing's current state (ruling, recurrence, explanations); its envelope's `confirmCommand` carries `--receipt <r1> --confirm-read`. **Step 2** (`--confirm-read`, after the user says they have read it) has the server verify r1 and return the stage-2 receipt; the CLI prints a second envelope for the ruling whose `confirmCommand` carries `--receipt <r2> --confirm`. **Step 3** (`--confirm`, after the user approves the ruling) writes. Confirming the ruling with the stage-1 receipt is refused — locally before any request, and by the server with 409 — and told to confirm the read first; a receipt that is missing, forged, expired (10-minute TTL), for another reader/ruling, or older than the crossing's latest change (a recurrence, a new explanation, a ruling) is refused too, nothing written — re-run without a step flag for a fresh envelope. Each envelope lists its receipt under `receipt: { token, expiresAt }`. The audit row records shown / read-acknowledged / confirmed times. This makes the order un-skippable; it does not, and cannot, prove the user personally ticked the box — you relay each step — so the ruling is recorded as a CLI-channel disposition.
53
54
 
54
55
  ## Commands that use the protocol
55
56
 
56
- | Command | `changes` describes | Notes |
57
- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `task update <id> --status done` | `Will move LUM-N "title" to DONE` · `Status: <current> → DONE` · one line per linked PR (`#926 merged title url`) or `Pull requests: none linked` · a trailing `⚠ N linked pull request(s) not merged: …` when any PR is open/closed/draft | Only `--status done` is gated; other statuses PATCH directly. The server's own DONE gates (unresolved send-back, open boundary crossing → 409) still apply after `--confirm`. See [tasks.md](tasks.md). |
59
- | `task deps rm <id> <edge>` | the resolved edge (short id, direction, other task, status, source) | [task-deps.md](task-deps.md) |
60
- | `task artifact rm <task> <artifact-id>` | artifact id, title, kind; "irreversible" | [artifacts-figma.md](artifacts-figma.md) |
61
- | `memory rm <memoryId>` | memory id, `[CATEGORY]`, a one-line content summary | [memory.md](memory.md) |
62
- | `doc delete <doc>` | doc id + title; nested documents are deleted with it | [docs.md](docs.md) |
63
- | `sprint delete <ref>` | sprint number + name; task count (tasks keep their data) | [sprints.md](sprints.md) |
64
- | `sprint close <ref> --move-all` / `--backlog-all` | sprint; every unfinished task and where it goes | Without a mode flag and with unfinished tasks the command is a plain exit-1 refusal (pick a mode). [sprints.md](sprints.md) |
65
- | `milestone delete <ref>` | milestone name; task count (tasks keep their data) | [milestones.md](milestones.md) |
66
- | `worktree rm <LUM-N>` | path + branch; `⚠ uncommitted changes discarded` when dirty; whether the branch is kept or deleted | Dirty tree → `confirmCommand` carries `--force --confirm`. [worktree.md](worktree.md) |
67
- | `doc rebuild-source <doc>` (when a source already exists) | the server's 409 reason; what `--force` would replace | `confirmCommand` carries `--force --confirm`. [doc-editing.md](doc-editing.md) |
57
+ | Command | `changes` describes | Notes |
58
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `task update <id> --status done` | `Will move LUM-N "title" to DONE` · `Status: <current> → DONE` · one line per linked PR (`#926 merged title url`) or `Pull requests: none linked` · a trailing `⚠ N linked pull request(s) not merged: …` when any PR is open/closed/draft | Only `--status done` is gated; other statuses PATCH directly. The server's own DONE gates (unresolved send-back, open boundary crossing → 409) still apply after `--confirm`. See [tasks.md](tasks.md). |
60
+ | `crossing disposition <id> --false-positive` / `--confirmed` | **Envelope 1 (read):** `Will disposition crossing <id> on LUM-N as FALSE_POSITIVE` · `Disposition: OPEN → FALSE_POSITIVE` · `[SEVERITY] CATEGORY — detail` · `Seen ×N while open` when it recurred · every agent explanation (`agent self-report · unverified`) or `Agent explanations: none recorded` · `Note to record: …` · `⚠ Approving clears this crossing's block on moving LUM-N to DONE` (or `Advisory crossing — it never blocked DONE…`) · `⚠ HIGH severity …` / `⚠ Irreversible category …` when they apply · `Step 1 of 2 — read receipt issued … valid until <time>`. **Envelope 2 (ruling, after `--confirm-read`):** `Read acknowledged for crossing <id> (shown <t1>, acknowledged <t2>)` · `Step 2 of 2 — record the ruling: OPEN → FALSE_POSITIVE on LUM-N` · the note · the ⚠ consequence line | LUM-769. Two envelopes, two approvals: envelope 1's `confirmCommand` ends in `--confirm-read`, envelope 2's in `--confirm` (see Flags). The crossing is read first on every path and the command fails closed (exit 1, no write) when that read or a receipt request errors; a step flag without its receipt, `--confirm` with the stage-1 receipt, or both step flags at once are refused before any request; an already-matching ruling is a no-op (exit 0). Reverting to OPEN and repository suppression rules stay web-only; steward (milestone) crossings are not reachable. See [verify.md](verify.md). |
61
+ | `task deps rm <id> <edge>` | the resolved edge (short id, direction, other task, status, source) | [task-deps.md](task-deps.md) |
62
+ | `task artifact rm <task> <artifact-id>` | artifact id, title, kind; "irreversible" | [artifacts-figma.md](artifacts-figma.md) |
63
+ | `memory rm <memoryId>` | memory id, `[CATEGORY]`, a one-line content summary | [memory.md](memory.md) |
64
+ | `doc delete <doc>` | doc id + title; nested documents are deleted with it | [docs.md](docs.md) |
65
+ | `sprint delete <ref>` | sprint number + name; task count (tasks keep their data) | [sprints.md](sprints.md) |
66
+ | `sprint close <ref> --move-all` / `--backlog-all` | sprint; every unfinished task and where it goes | Without a mode flag and with unfinished tasks the command is a plain exit-1 refusal (pick a mode). [sprints.md](sprints.md) |
67
+ | `milestone delete <ref>` | milestone name; task count (tasks keep their data) | [milestones.md](milestones.md) |
68
+ | `worktree rm <LUM-N>` | path + branch; `⚠ uncommitted changes discarded` when dirty; whether the branch is kept or deleted | Dirty tree → `confirmCommand` carries `--force --confirm`. [worktree.md](worktree.md) |
69
+ | `doc rebuild-source <doc>` (when a source already exists) | the server's 409 reason; what `--force` would replace | `confirmCommand` carries `--force --confirm`. [doc-editing.md](doc-editing.md) |
68
70
 
69
71
  ## When the gate is human-only: `DONE_BLOCKED`, exit code 5
70
72
 
71
- Not every refusal is something the user can approve. The server's DONE gates — an **unresolved send-back**, an **undispositioned boundary crossing**, a **blocking security finding** — are verdicts on the agent's own work, and by design there is no CLI path to clear them (the interested party may not adjudicate itself). They are therefore **not** part of the confirmation protocol: no `--confirm`, no `--force`, nothing you can pass.
73
+ Not every refusal is something the user can approve on the spot. The server's DONE gates — an **unresolved send-back**, an **undispositioned boundary crossing**, a **blocking security finding** — are verdicts on the agent's own work, and the interested party may not adjudicate itself: nothing on `task update` clears them no `--confirm`, no `--force`, nothing you can pass. Since LUM-769 a **boundary crossing** does have a terminal path, but it is a separate command and it is the user's: `lumo crossing disposition <id> --false-positive | --confirmed` walks the exit-4 protocol above (you relay the crossing, the user rules, you re-run the `confirmCommand`). Send-backs and security findings still have no CLI path at all.
72
74
 
73
75
  What the CLI does instead: when `task update <id> --status done --confirm` is refused by one of these gates, it re-reads the task's acceptance status and open crossings, prints a structured error on **stdout** and exits **5**:
74
76
 
@@ -90,7 +92,7 @@ What the CLI does instead: when `task update <id> --status done --confirm` is re
90
92
  ],
91
93
  "unconfirmed": [],
92
94
  "remediation": [
93
- "BOUNDARY_CROSSING: leave a rationale with `lumo crossing explain cmtw… --note \"…\"` (append-only; it does not clear anything). A human dispositions each crossing (false positive / confirmed) at https://…/my-tasks/LUM-48#boundary-crossings"
95
+ "BOUNDARY_CROSSING: leave a rationale with `lumo crossing explain cmtw… --note \"…\"` (append-only; it does not clear anything), then relay each crossing to the user. Once they rule, record it with `lumo crossing disposition cmtw… --false-positive` or `--confirmed` it prints an exit-4 envelope for the user to approve; never self-approve. The web panel also works: https://…/my-tasks/LUM-48#boundary-crossings"
94
96
  ],
95
97
  "dispositionUrl": "https://…/my-tasks/LUM-48#boundary-crossings"
96
98
  }
@@ -101,9 +103,9 @@ What the CLI does instead: when `task update <id> --status done --confirm` is re
101
103
  | `message` | The server's 409 text, verbatim. |
102
104
  | `blockers` | One entry per item holding the gate: `SEND_BACK` (criterionId, statement, failedAtRound, rejectionReason), `BOUNDARY_CROSSING` (id, severity, category, detail), `SECURITY_FINDING` (findingId, severity, ruleId, filePath, line, prNumber, statement). Built from the same read models as `lumo task status`. |
103
105
  | `unconfirmed` | Enrichment reads that failed (`boundary-crossings read failed: HTTP 500`). Non-empty means the blocker list may be incomplete — it never silently shrinks. |
104
- | `remediation` | One line per blocker kind present, naming only human-side or append-only paths: re-run `lumo verify`, `lumo crossing explain`, fix-and-push, the web disposition panel. |
106
+ | `remediation` | One line per blocker kind present, naming only human-side, append-only or user-approved paths: re-run `lumo verify`, `lumo crossing explain`, `lumo crossing disposition` (exit-4 envelope, the user rules), fix-and-push, the web disposition panel. Never a ready-to-run approval flag. |
105
107
 
106
- **Exit 4 vs exit 5:** 4 means "the user must approve, then you can proceed"; 5 means "a human must adjudicate elsewhere — nothing on the CLI moves this". On exit 5, relay `blockers` and `remediation` to the user. Do not retry, do not look for a flag, and do not reword or delete a failed criterion to get past a send-back (that is tampering and is audited). A 409 that is not a DONE gate (e.g. a permission refusal) keeps the plain exit-1 text on stderr.
108
+ **Exit 4 vs exit 5:** 4 means "the user must approve, then you can proceed"; 5 means "a human must adjudicate — nothing on this command moves it". On exit 5, relay `blockers` and `remediation` to the user. Do not retry `task update`, do not look for a flag on it, and do not reword or delete a failed criterion to get past a send-back (that is tampering and is audited). For a `BOUNDARY_CROSSING` blocker the adjudication can happen in the terminal — `lumo crossing disposition <id> …` is itself an exit-4 command, so the user's ruling still comes through the protocol, never from you. Once every blocker is cleared, re-run the original `task update … --confirm` (its envelope was already approved). A 409 that is not a DONE gate (e.g. a permission refusal) keeps the plain exit-1 text on stderr.
107
109
 
108
110
  ## Example exchange
109
111
 
@@ -142,7 +142,7 @@ lumo session attach --steward cmxyz123... # by milestone id
142
142
  - Moving a task to `in_progress` with **yourself as the effective assignee** (whether already assigned to you or assigned in the same call) → 409. Dispatching to _someone else_ is planning and passes.
143
143
  - Red line unchanged: a steward writes no PASS of any kind; DONE stays human-only.
144
144
 
145
- **Backstop (second layer, detector):** commits / PR creations produced during a steward session anyway (the CLI can't stop local git) are recorded as **`STEWARD_IMPLEMENTATION_ARTIFACT` boundary crossings against the milestone** — fail-closed (an unparseable Bash payload records an _unconfirmed_ crossing rather than nothing), disposition stays **web + human-only**, and there is **no CLI clear path** (`crossing explain` remains an appeal, never a clear).
145
+ **Backstop (second layer, detector):** commits / PR creations produced during a steward session anyway (the CLI can't stop local git) are recorded as **`STEWARD_IMPLEMENTATION_ARTIFACT` boundary crossings against the milestone** — fail-closed (an unparseable Bash payload records an _unconfirmed_ crossing rather than nothing), disposition stays **web + human-only** — `lumo crossing disposition` (LUM-769) is task-scoped and cannot reach a milestone crossing — and there is **no CLI clear path** (`crossing explain` remains an appeal, never a clear).
146
146
 
147
147
  #### When to suggest
148
148
 
@@ -148,7 +148,7 @@ lumo task update LUM-48 --status done
148
148
  With no linked PR the PR lines collapse to `Pull requests: none linked` and there is no `⚠` line. Other flags on the same call (`--title`, `--add-tag`…) are preserved in `confirmCommand`.
149
149
 
150
150
  - **Show `changes` to the user and wait for an explicit yes**, then run `confirmCommand` unchanged. `--confirm` means the user confirmed — never add it on your own.
151
- - With `--confirm` the PATCH goes out directly. The server's own DONE gates still apply on top — an unresolved send-back, an undispositioned boundary crossing or a blocking security finding refuses with **409**, which the CLI turns into a structured **`DONE_BLOCKED` error on stdout with exit 5**: the 409 text verbatim, a `blockers[]` with ids (built from `task status` + the crossings read model), and `remediation` lines that only name human-side paths. There is no CLI path to clear any of them — relay, don't retry. See [confirmation.md](confirmation.md) "When the gate is human-only". Any other 409 stays plain text, exit 1.
151
+ - With `--confirm` the PATCH goes out directly. The server's own DONE gates still apply on top — an unresolved send-back, an undispositioned boundary crossing or a blocking security finding refuses with **409**, which the CLI turns into a structured **`DONE_BLOCKED` error on stdout with exit 5**: the 409 text verbatim, a `blockers[]` with ids (built from `task status` + the crossings read model), and `remediation` lines that only name human-side or user-approved paths. Nothing on `task update` clears any of them — relay, don't retry. A boundary crossing is the one blocker with a terminal path, `lumo crossing disposition <id> …` (itself exit-4: the user rules, never you); once every blocker is cleared, re-run this `--confirm` call. See [confirmation.md](confirmation.md) "When the gate is human-only". Any other 409 stays plain text, exit 1.
152
152
  - `--confirm` is the only flag: there is no `--yes` or `--force` on `task update`. Every other status (`todo` / `in_progress` / `in_review`) PATCHes directly and ignores `--confirm`.
153
153
  - A failed lookup (404, network) exits 1 before any envelope.
154
154
 
@@ -142,12 +142,13 @@ what's unmet and why (the exact failure tails), and how many rounds are left.
142
142
 
143
143
  - **Trend** (`--full`) — 规律 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.
144
144
 
145
- - **Next actions** — the unmet criteria (latest verdict is not a pass: failed or never verified, HUMAN ones included) followed by any **undispositioned PR security findings** (LUM-737) as `• [SECURITY] [SEVERITY] PROVENANCE ruleId — file:line — title (PR #n · blocks DONE | advisory)`. This list IS the plan — recomputed from the event log + the latest scan per linked PR on every read, never maintained separately. The header counts them separately: `Next actions (N unmet · M security findings)`. A finding is **not** a criterion: fix it and push (a fixed fingerprint disappears from the next scan) or a human dispositions it in the web delivery panel — there is no CLI path to clear one, and a `blocks DONE` finding refuses DONE with 409. Empty + rounds recorded = awaiting human adjudication.
146
- - **Per-PR scan status** (LUM-735): before the unconfirmed-PR lines, one line per linked PR that has a latest scan (open or closed alike): `PR #945 · scan CLEAN · secrets RAN · external RAN (2 external findings) · supplyChain RAN (1 dependency finding, 1 already on main) · judge RAN · hunt PARTIAL` — stage segments only for keys present, in secrets/external/supplyChain/judge/hunt order (`hunt` = the L3 vulnerability hunt, LUM-739: absent when `LUMO_SECURITY_HUNT=off`, `SKIPPED` when no trigger fired, `PENDING` while the workflow runs, `RAN` / `PARTIAL` (budget or subgraph cap hit) / `FAILED`; its findings are `LLM_JUDGE` advisory, never `blocks DONE`); `(N external findings)` (singular at 1) decorates only the external segment, and only when N > 0; `(N dependency findings[, M already on main])` (LUM-738; singular at 1) decorates only the supplyChain segment, only when N > 0, and prints bare `supplyChain <state>` against an older server without the counts; ` · partial` appended when the scan is partial; and, when `stages.external === 'FAILED'` and a **scrubbed scanner reason** was recorded (the `error` column carries the `external: ` provenance prefix), an appended ` — <reason without the prefix, tail 200 chars>` — an unprefixed `error` (stage A's own raw crash text) is never printed here, same P8 rule as the web panel and the PR summary (LUM-756). Fed by the additive `securityFindings.scans` array; omitted entirely (no lines) against an older server that doesn't send it.
145
+ - **Next actions** — the unmet criteria (latest verdict is not a pass: failed or never verified, HUMAN ones included) followed by any **undispositioned PR security findings** (LUM-737) as `• [SECURITY] [SEVERITY] PROVENANCE ruleId — file:line — title (PR #n · blocks DONE | advisory[ — <cause>, downgraded])`. The trailing **downgrade cause** appears only on deterministic `secret:*` rows the scanner lowered to LOW — `placeholder value` (the matched string looks like `…EXAMPLE`, `YOUR_…`, `CHANGEME`…) or `test/fixture/doc path`. Downgrading is **not** suppression: the row is still reported so a human decides, and the cause is what lets you tell a recognised test fixture from a real leak without opening the file. Absent on rows scanned before the cause was recorded, and on any row that was not downgraded — silence means "not downgraded", never "cause unknown". This list IS the plan — recomputed from the event log + the latest scan per linked PR on every read, never maintained separately. The header counts them separately: `Next actions (N unmet · M security findings)`. A finding is **not** a criterion: fix it and push (a fixed fingerprint disappears from the next scan) or a human dispositions it in the web delivery panel — there is no CLI path to clear one, and a `blocks DONE` finding refuses DONE with 409. Empty + rounds recorded = awaiting human adjudication.
146
+ - **Per-PR scan status** (LUM-735): before the unconfirmed-PR lines, one line per linked PR that has a latest scan (open or closed alike): `PR #945 · scan CLEAN · Secrets: checked · Code scan: checked (2 external findings) · Dependencies: checked (1 dependency finding, 1 already on main) · AI review: checked · Exploit paths: ⚠ incomplete` — stage segments only for keys present, in secrets/external/supplyChain/judge/hunt order. **The stage keys are internal; what prints is their display label** (LUM-763, `shared/src/security-scan.ts`, shared with the GitHub PR summary so the two cannot drift): `secrets`→Secrets, `external`→Code scan, `supplyChain`→Dependencies, `judge`→AI review, `hunt`→Exploit paths; states render as `RAN`→checked, `SKIPPED`→skipped, `NOT_CONFIGURED`→off, `PENDING`→scanning, `PARTIAL`→⚠ incomplete, `SUPERSEDED`→⚠ superseded, `FAILED`→✗ failed. "checked" means the layer completed, **not** that it found nothing — findings are counted in their own segments and in the next-actions list (`hunt` = the L3 vulnerability hunt, LUM-739: absent when `LUMO_SECURITY_HUNT=off`, `SKIPPED` when no trigger fired, `PENDING` while the workflow runs, `RAN` / `PARTIAL` (budget hit, a planned task that never finished, or more surviving candidates than the falsifier cap) / `FAILED`; its findings are `LLM_JUDGE` advisory, never `blocks DONE`); `(N external findings)` (singular at 1) decorates only the Code scan segment, and only when N > 0; `(N dependency findings[, M already on main])` (LUM-738; singular at 1) decorates only the Dependencies segment, only when N > 0, and prints bare `Dependencies: <state>` against an older server without the counts; ` · partial` appended when the scan is partial; and, when `stages.external === 'FAILED'` and a **scrubbed scanner reason** was recorded (the `error` column carries the `external: ` provenance prefix), an appended ` — <reason without the prefix, tail 200 chars>` — an unprefixed `error` (stage A's own raw crash text) is never printed here, same P8 rule as the web panel and the PR summary (LUM-756). Fed by the additive `securityFindings.scans` array; omitted entirely (no lines) against an older server that doesn't send it.
147
+ - **Hunt coverage line** (LUM-762): indented under its scan line whenever the server sends the L3 hunt's per-category audit — `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done (2 attempts) · t3 race skipped`. `<n>/N done` is how many planned tasks finished (where N is the number of tasks the planning step selected for this scan, typically 0–3). Rows persisted before LUM-758 lack a planned-task count and fall back to the historical 6-category denominator (reading as `/6` instead). Each segment is `[taskId ]<category> <elapsed>s <stop>` — the `taskId` prefix (`t1`, `t2`, …) appears only on current rows where the task graph is present (post-LUM-758), and is omitted on pre-task-graph audit rows, so they render exactly as they always did. Elapsed is summed over the task's attempts; `(N attempts)` when the workflow retried it; no elapsed for a `skipped` task (the scan's budget ran out before its turn). Stop values: `done` · `steps` · `budget` · `timeout` · `error` · `skipped`. Headed by the same display label as the layer's own segment (LUM-763), so the detail line and the line above it never name the layer two different ways. This is the only surface that reads `huntAudit`, so it is where you answer "how long does a healthy task take" and "which tasks never ran" without paging through deploy logs. Absent on an older server and on scans with no usable audit (pre-LUM-739 rows, or a hunt that failed before its first batch) — no line, never a guess.
147
148
  - **Persisting findings** (LUM-738): a finding whose fingerprint is already on the repository's default-branch baseline (`persisting`) is not this PR's — it never appears as a next action, is not counted in `openFindings`, and shows muted (_already on the default branch_) in the web panel. Dependency findings (`kind=DEPENDENCY`, from osv-scanner) are advisory: they never block DONE.
148
149
  - **Fails closed:** `⚠ Security-scan check failed — could not confirm …` when the scan read errored, and one `⚠ PR #n: … — could not confirm it is clean.` line per open PR whose latest scan is missing / FAILED / still running. Silence means a successful read with nothing open, never a failed check.
149
- - **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.
150
- - **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.
150
+ - **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 `lumo crossing disposition` and the web acceptance panel. Silent when there are none, so it never overshadows the criteria.
151
+ - **Read-only awareness** — this block surfaces crossings detected elsewhere and clears nothing itself. The ruling is the user's: `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope you relay, LUM-769 see below) or the web panel. You never self-approve.
151
152
  - **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".
152
153
 
153
154
  ### Responding to an open crossing — `lumo crossing explain`
@@ -163,11 +164,32 @@ lumo crossing explain <id> --note "this was a generated fixture, not a hand-edit
163
164
  This is the **inverse** of dispositioning, but it is the agent/CLI path
164
165
  (bearer-only; a clerk/human caller is refused). Behavior:
165
166
 
166
- - it can **only append** an append-only note — it **never clears the crossing or unblocks Done** (disposition stays web + human-only);
167
+ - it can **only append** an append-only note — it **never clears the crossing or unblocks Done** (the user rules, via `lumo crossing disposition` or the web panel);
167
168
  - the note is shown to the human reviewer at disposition time, kept for later review, and explicitly labeled _agent self-report · unverified_;
168
169
  - `<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);
169
170
  - earlier explanations are immutable — a correction is a new note.
170
171
 
172
+ ### Recording the user's ruling — `lumo crossing disposition` (LUM-769)
173
+
174
+ When the user has ruled on a crossing — in chat, after you relayed it — record
175
+ that ruling from the terminal instead of sending them to the web panel:
176
+
177
+ ```bash
178
+ lumo crossing disposition <id> --false-positive --note "covered by the run-outbound tests"
179
+ ```
180
+
181
+ It is a gated mutation on the [confirmation protocol](confirmation.md), as a **three-step** handshake — two envelopes, two separate approvals from the user, and the server enforces the order:
182
+
183
+ - **Step 1 — no step flag.** Reads the crossing, obtains a server-signed stage-1 **read receipt**, and prints an exit-4 envelope — severity, category, detail, `Seen ×N` when it recurred, every explanation you left (labelled _agent self-report · unverified_), the note, a `⚠` line saying that approving clears this crossing's block on DONE (plus `⚠ HIGH severity` / `⚠ Irreversible category` when they apply), and `Step 1 of 2 — read receipt issued … valid until <time>`. Nothing is written. Relay every line and **ask the user to confirm they have read it** — this envelope's `confirmCommand` ends in `--receipt <r1> --confirm-read`, not `--confirm`;
184
+ - **Step 2 — `--confirm-read --receipt <r1>`**, only after the user says they read it. The server verifies r1 and returns the stage-2 receipt; the CLI prints a second exit-4 envelope for the ruling itself — `Read acknowledged … (shown <t1>, acknowledged <t2>)`, `Step 2 of 2 — record the ruling: OPEN → FALSE_POSITIVE`, the note, the ⚠ consequence line. Still nothing written. Relay it and **ask the user to approve the ruling** — its `confirmCommand` ends in `--receipt <r2> --confirm`;
185
+ - **Step 3 — `--confirm --receipt <r2>`**, only after the user approves. POSTs the ruling (`FALSE_POSITIVE` | `CONFIRMED`, optional note, the stage-2 receipt); either value clears that crossing's DONE block, and the audit row records the shown / read-acknowledged / confirmed times. The web panel can still correct it later. **Never add `--confirm-read` or `--confirm` yourself** — each is the user's approval of that step, and the server records the ruling as a CLI-channel disposition (shown as _via CLI_ in the panel) precisely because it cannot verify the in-chat approvals;
186
+ - **the receipts are the server's ordering check** (LUM-769): each is bound to the crossing, the ruling, your session/member and the crossing's state at read time, and expires after 10 minutes. Confirming the ruling with the stage-1 receipt (read not yet acknowledged) is refused — locally before any request, and by the server with 409 — and told to run `--confirm-read` first; a step flag without its receipt, or both step flags at once, is refused locally; a receipt that is forged, expired, for another reader/ruling, or older than the crossing's latest change (a recurrence, a new explanation, a ruling) is refused by the server with 409 and nothing is written — re-run without a step flag for a fresh envelope. If the server cannot issue receipts (no signing secret configured) step 1 fails closed and no envelope is printed;
187
+ - the crossing is read first on both paths: a read that errors **fails closed** (exit 1, nothing written), an id not on the task is exit 1, and an already-matching ruling is a no-op (exit 0);
188
+ - `<id>` resolves against the **session-bound task**; `--task <LUM-N>` overrides the binding (e.g. from a terminal with no session, or after DONE unbinds you);
189
+ - **still web-only:** reverting a ruling to OPEN, repository suppression rules (LUM-751), and steward milestone crossings (`STEWARD_IMPLEMENTATION_ARTIFACT` has no task).
190
+
191
+ After the last blocker is cleared, re-run the original `lumo task update <id> --status done --confirm` — its envelope was already approved.
192
+
171
193
  ### --json contract
172
194
 
173
195
  `--json` emits the full read model with a top-level `version` field (currently
@@ -0,0 +1,342 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeCrossingDisposition = describeCrossingDisposition;
4
+ exports.describeRulingConfirmation = describeRulingConfirmation;
5
+ exports.crossingDisposition = crossingDisposition;
6
+ const config_1 = require("../lib/config");
7
+ const api_1 = require("../lib/api");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ const confirmation_1 = require("../lib/confirmation");
10
+ const bound_task_1 = require("../lib/bound-task");
11
+ const open_crossings_1 = require("../lib/open-crossings");
12
+ /**
13
+ * The changes[] block of the read envelope (step 1, exit 4): what ruling is
14
+ * about to be recorded, on which crossing, with everything the user needs to
15
+ * judge it — severity, category, detail, recurrence, and the agent's own
16
+ * explanations (labelled as an unverified self-report). The ⚠ lines carry
17
+ * the consequence: approving clears this crossing's block on DONE.
18
+ */
19
+ function describeCrossingDisposition(crossing, target, ctx) {
20
+ const lines = [
21
+ `Will disposition crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} on ${ctx.taskIdentifier} as ${target}`,
22
+ `Disposition: ${crossing.disposition ?? 'OPEN'} → ${target}`,
23
+ ];
24
+ const detail = (0, sanitize_1.sanitizeField)(crossing.detail).replace(/\s+/g, ' ').trim();
25
+ lines.push(`[${crossing.severity}] ${(0, sanitize_1.sanitizeField)(crossing.category)}${detail ? ` — ${detail}` : ''}`);
26
+ if ((crossing.occurrenceCount ?? 1) > 1) {
27
+ lines.push(`Seen ×${crossing.occurrenceCount} while open`);
28
+ }
29
+ const explanations = crossing.explanations ?? [];
30
+ if (explanations.length === 0) {
31
+ lines.push('Agent explanations: none recorded');
32
+ }
33
+ else {
34
+ lines.push(`Agent explanations (${explanations.length}, agent self-report · unverified):`);
35
+ for (const e of explanations) {
36
+ lines.push(` - ${(0, sanitize_1.sanitizeField)(e.note).replace(/\s+/g, ' ').trim()}`);
37
+ }
38
+ }
39
+ if (ctx.note)
40
+ lines.push(`Note to record: ${(0, sanitize_1.sanitizeField)(ctx.note)}`);
41
+ if (crossing.advisory) {
42
+ lines.push('Advisory crossing — it never blocked DONE; the ruling is for the record only');
43
+ }
44
+ else {
45
+ lines.push(`⚠ Approving clears this crossing's block on moving ${ctx.taskIdentifier} to DONE`);
46
+ }
47
+ if (crossing.severity === 'HIGH') {
48
+ lines.push('⚠ HIGH severity — this class always needs a human decision; make sure the user has read the detail above');
49
+ }
50
+ if (crossing.reversible === false) {
51
+ lines.push('⚠ Irreversible category — the action reached outside the repo; a false-positive ruling should rest on the detail, not on the explanation alone');
52
+ }
53
+ return lines;
54
+ }
55
+ /**
56
+ * The changes[] block of the ruling envelope (step 2, exit 4): the read is
57
+ * acknowledged and recorded; what remains is the ruling itself.
58
+ */
59
+ function describeRulingConfirmation(crossing, target, ctx) {
60
+ const lines = [
61
+ `Read acknowledged for crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} (shown ${ctx.readAt}, acknowledged ${ctx.readAcknowledgedAt}) — recorded with the ruling`,
62
+ `Step 2 of 2 — record the ruling: ${crossing.disposition ?? 'OPEN'} → ${target} on ${ctx.taskIdentifier}`,
63
+ ];
64
+ if (ctx.note)
65
+ lines.push(`Note to record: ${(0, sanitize_1.sanitizeField)(ctx.note)}`);
66
+ lines.push(crossing.advisory
67
+ ? 'Advisory crossing — it never blocked DONE; the ruling is for the record only'
68
+ : `⚠ Approving clears this crossing's block on moving ${ctx.taskIdentifier} to DONE`);
69
+ return lines;
70
+ }
71
+ /**
72
+ * `lumo crossing disposition <id> --false-positive | --confirmed [--note …]
73
+ * [--task LUM-N] [--receipt <token>] [--confirm-read | --confirm]` — rule on
74
+ * a boundary crossing from the terminal (LUM-769), through a THREE-step
75
+ * confirmation handshake the server enforces:
76
+ *
77
+ * 1. no step flag: read the crossing, obtain a stage-1 READ RECEIPT
78
+ * (bound to crossing, ruling, caller and the crossing's current state),
79
+ * print the read envelope (exit 4), write nothing. Its confirmCommand
80
+ * carries `--receipt <r1> --confirm-read`;
81
+ * 2. `--confirm-read --receipt <r1>`: the user confirmed they read it. The
82
+ * server verifies r1 and issues the stage-2 receipt; the CLI prints the
83
+ * ruling envelope (exit 4), write nothing. Its confirmCommand carries
84
+ * `--receipt <r2> --confirm`;
85
+ * 3. `--confirm --receipt <r2>`: the user approved the ruling. The server
86
+ * verifies r2 — a stage-1 receipt is refused with "confirm read first",
87
+ * as is anything forged, expired, foreign or stale — then writes, with
88
+ * the shown / acknowledged / confirmed timestamps on the audit row.
89
+ *
90
+ * Skipping or reordering a step is refused at the CLI before any request
91
+ * where it can be told locally (no receipt, a stage-1 receipt with --confirm,
92
+ * a stage-2 receipt with --confirm-read) and by the server otherwise. The
93
+ * agent relays each envelope and never supplies a step flag on its own.
94
+ * Server-side the ruling is stamped `channel: 'CLI'` so the audit trail keeps
95
+ * it apart from a web-panel click. What stays web-only: reverting to OPEN and
96
+ * the repository suppression rule — neither exists on this path.
97
+ */
98
+ async function crossingDisposition(crossingId, options = {}) {
99
+ if (!crossingId || crossingId.trim() === '') {
100
+ console.error('Error: a crossing id is required: lumo crossing disposition <id> --false-positive | --confirmed');
101
+ return 1;
102
+ }
103
+ const target = pickTarget(options);
104
+ if (!target) {
105
+ console.error('Error: pass exactly one of --false-positive or --confirmed (the ruling to record).');
106
+ return 1;
107
+ }
108
+ if (options.confirmRead && options.confirm) {
109
+ console.error('Error: --confirm-read and --confirm are separate steps — run them one at a time, each from the envelope the previous step printed.');
110
+ return 1;
111
+ }
112
+ const note = options.note?.trim() || undefined;
113
+ const receipt = options.receipt?.trim() || undefined;
114
+ // Local ordering checks — cheap, and they name the missing step before any
115
+ // request goes out. The server re-checks all of them.
116
+ if (options.confirm) {
117
+ if (!receipt) {
118
+ console.error('Error: --confirm requires the stage-2 read receipt (--receipt <token>) from the --confirm-read step. Run the command without any step flag first, relay the envelope, confirm the read, relay that envelope, then re-run its confirmCommand verbatim.');
119
+ return 1;
120
+ }
121
+ if (receipt.startsWith('r1.')) {
122
+ console.error('Error: read not yet acknowledged — this is the stage-1 receipt. Run the command with --confirm-read --receipt <this token> first (after the user confirms they read the envelope); only the receipt that step returns is accepted with --confirm.');
123
+ return 1;
124
+ }
125
+ }
126
+ if (options.confirmRead) {
127
+ if (!receipt) {
128
+ console.error('Error: --confirm-read requires the read receipt (--receipt <token>) from the envelope. Run the command without any step flag first and relay the envelope.');
129
+ return 1;
130
+ }
131
+ if (receipt.startsWith('r2.')) {
132
+ console.error('Error: this receipt already acknowledges the read — the next step is --confirm with this same receipt (after the user approves the ruling).');
133
+ return 1;
134
+ }
135
+ }
136
+ const creds = (0, config_1.readCredentials)();
137
+ if (!creds) {
138
+ console.error('Error: not logged in. Run `lumo auth login` first.');
139
+ return 1;
140
+ }
141
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
142
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
143
+ const headers = {
144
+ Authorization: `Bearer ${creds.token}`,
145
+ };
146
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
147
+ if (sessionId)
148
+ headers['X-Lumo-Session-Id'] = sessionId;
149
+ const bound = await (0, bound_task_1.resolveBoundTask)({
150
+ base,
151
+ headers,
152
+ explicit: options.task,
153
+ sessionId,
154
+ });
155
+ if (!bound.ok) {
156
+ console.error(bound.message);
157
+ return 1;
158
+ }
159
+ const taskId = bound.taskIdentifier;
160
+ // Read the crossing first, on every path: the envelopes need it, and a
161
+ // confirmed write against a crossing that can't be read must not go out
162
+ // (fail closed — a read hiccup never turns into a blind ruling).
163
+ const read = await readCrossing(base, headers, taskId, crossingId);
164
+ if (read.status === 'error') {
165
+ console.error(`Error: could not confirm the crossing (${read.reason}) — nothing was written.`);
166
+ return 1;
167
+ }
168
+ const crossing = read.crossing;
169
+ if (!crossing) {
170
+ console.error(`Error: crossing ${(0, sanitize_1.sanitizeField)(crossingId)} not found on ${taskId}. Ids are listed by \`lumo task status ${taskId}\`.`);
171
+ return 1;
172
+ }
173
+ if (crossing.disposition === target) {
174
+ process.stdout.write(`Crossing ${(0, sanitize_1.sanitizeField)(crossing.id)} is already ${target} — nothing to do.\n`);
175
+ return;
176
+ }
177
+ // Step 1 — read envelope.
178
+ if (!options.confirmRead && !options.confirm) {
179
+ const issued = await requestReceipt(base, headers, taskId, crossingId, {
180
+ disposition: target,
181
+ });
182
+ if (issued.status === 'error') {
183
+ console.error(`Error: could not obtain a read receipt for the crossing (${issued.reason}) — nothing was written and no envelope can be issued.`);
184
+ return 1;
185
+ }
186
+ return (0, confirmation_1.emitConfirmation)({
187
+ command: 'crossing disposition',
188
+ changes: [
189
+ ...describeCrossingDisposition(crossing, target, {
190
+ taskIdentifier: taskId,
191
+ note,
192
+ }),
193
+ `Step 1 of 2 — read receipt issued for this crossing as read above (valid until ${issued.expiresAt}; void if the crossing changes). Next: the user confirms they have READ it, then run the command below; the ruling itself is confirmed in a second envelope`,
194
+ ],
195
+ confirmFlag: '--confirm-read',
196
+ receipt: { token: issued.receipt, expiresAt: issued.expiresAt },
197
+ });
198
+ }
199
+ // Step 2 — acknowledge the read, get the stage-2 receipt, ruling envelope.
200
+ if (options.confirmRead) {
201
+ const issued = await requestReceipt(base, headers, taskId, crossingId, {
202
+ disposition: target,
203
+ acknowledge: receipt,
204
+ });
205
+ if (issued.status === 'error') {
206
+ console.error(`Error: read acknowledgement rejected (${issued.reason}) — nothing was written.`);
207
+ return 1;
208
+ }
209
+ return (0, confirmation_1.emitConfirmation)({
210
+ command: 'crossing disposition',
211
+ changes: describeRulingConfirmation(crossing, target, {
212
+ taskIdentifier: taskId,
213
+ note,
214
+ readAt: issued.readAt,
215
+ readAcknowledgedAt: issued.readAcknowledgedAt ?? issued.readAt,
216
+ }),
217
+ confirmFlag: '--confirm',
218
+ receipt: { token: issued.receipt, expiresAt: issued.expiresAt },
219
+ });
220
+ }
221
+ // Step 3 — the write.
222
+ let res;
223
+ try {
224
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings/${encodeURIComponent(crossingId)}/disposition`, {
225
+ method: 'POST',
226
+ headers: { ...headers, 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({
228
+ disposition: target,
229
+ ...(note ? { dispositionNote: note } : {}),
230
+ receipt,
231
+ }),
232
+ });
233
+ }
234
+ catch (err) {
235
+ const msg = err instanceof Error ? err.message : String(err);
236
+ console.error(`Error: could not reach Lumo API (${msg})`);
237
+ return 1;
238
+ }
239
+ if (res.status === 401) {
240
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
241
+ return 1;
242
+ }
243
+ if (!res.ok) {
244
+ const errBody = (await res.json().catch(() => null));
245
+ const detail = errBody && typeof errBody.error === 'string'
246
+ ? (0, sanitize_1.sanitizeField)(errBody.error)
247
+ : '';
248
+ console.error(`Error: disposition rejected (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
249
+ return 1;
250
+ }
251
+ const outcome = (await res.json());
252
+ const url = (0, open_crossings_1.dispositionUrl)(apiUrl, creds.workspaceSlug ?? '', taskId);
253
+ process.stdout.write(`✓ Dispositioned crossing ${(0, sanitize_1.sanitizeField)(outcome.crossingId)} as ${outcome.disposition ?? 'OPEN'} (read acknowledged and ruling approved via CLI).\n` +
254
+ (crossing.advisory
255
+ ? ''
256
+ : ` This clears its block on moving ${taskId} to DONE. `) +
257
+ `The ruling is audited on the task timeline as a CLI-channel disposition with the shown / read-acknowledged / confirmed times; it can be corrected in the web panel: ${url}\n`);
258
+ return;
259
+ }
260
+ function pickTarget(options) {
261
+ if (options.falsePositive && options.confirmed)
262
+ return null;
263
+ if (options.falsePositive)
264
+ return 'FALSE_POSITIVE';
265
+ if (options.confirmed)
266
+ return 'CONFIRMED';
267
+ return null;
268
+ }
269
+ /** The handshake's server call (LUM-769): stage 1 without `acknowledge`,
270
+ * stage 2 with the stage-1 receipt in it. Fails closed — without a receipt
271
+ * no envelope is issued, because its confirmCommand could never be accepted. */
272
+ async function requestReceipt(base, headers, taskId, crossingId, body) {
273
+ let res;
274
+ try {
275
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings/${encodeURIComponent(crossingId)}/disposition/receipt`, {
276
+ method: 'POST',
277
+ headers: { ...headers, 'Content-Type': 'application/json' },
278
+ body: JSON.stringify(body),
279
+ });
280
+ }
281
+ catch (err) {
282
+ return {
283
+ status: 'error',
284
+ reason: err instanceof Error ? err.message : 'network error',
285
+ };
286
+ }
287
+ if (!res.ok) {
288
+ const errBody = (await res.json().catch(() => null));
289
+ const detail = errBody && typeof errBody.error === 'string'
290
+ ? ` — ${(0, sanitize_1.sanitizeField)(errBody.error)}`
291
+ : '';
292
+ return { status: 'error', reason: `HTTP ${res.status}${detail}` };
293
+ }
294
+ let data;
295
+ try {
296
+ data = (await res.json());
297
+ }
298
+ catch {
299
+ return { status: 'error', reason: 'invalid response body' };
300
+ }
301
+ if (typeof data.receipt !== 'string' || data.receipt.length === 0) {
302
+ return { status: 'error', reason: 'no receipt in response' };
303
+ }
304
+ const str = (v) => (typeof v === 'string' ? v : '');
305
+ return {
306
+ status: 'ok',
307
+ receipt: data.receipt,
308
+ expiresAt: str(data.expiresAt),
309
+ readAt: str(data.readAt),
310
+ readAcknowledgedAt: typeof data.readAcknowledgedAt === 'string'
311
+ ? data.readAcknowledgedAt
312
+ : null,
313
+ };
314
+ }
315
+ /** One task-scoped read of the LUM-435 view; the crossing picked by id. Fails
316
+ * closed: transport / non-ok / unparseable → error, never "not found". */
317
+ async function readCrossing(base, headers, taskId, crossingId) {
318
+ let res;
319
+ try {
320
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/boundary-crossings`, { headers });
321
+ }
322
+ catch (err) {
323
+ return {
324
+ status: 'error',
325
+ reason: err instanceof Error ? err.message : 'network error',
326
+ };
327
+ }
328
+ if (res.status === 401) {
329
+ return { status: 'error', reason: 'API key invalid or revoked' };
330
+ }
331
+ if (!res.ok)
332
+ return { status: 'error', reason: `HTTP ${res.status}` };
333
+ let data;
334
+ try {
335
+ data = (await res.json());
336
+ }
337
+ catch {
338
+ return { status: 'error', reason: 'invalid response body' };
339
+ }
340
+ const rows = Array.isArray(data.crossings) ? data.crossings : [];
341
+ return { status: 'ok', crossing: rows.find(c => c.id === crossingId) ?? null };
342
+ }
@@ -4,14 +4,16 @@ exports.crossingExplain = crossingExplain;
4
4
  const config_1 = require("../lib/config");
5
5
  const api_1 = require("../lib/api");
6
6
  const sanitize_1 = require("../lib/sanitize");
7
+ const bound_task_1 = require("../lib/bound-task");
7
8
  /**
8
9
  * `lumo crossing explain <id> --note "…"` — append an agent self-explanation
9
10
  * ("申辩") to a boundary crossing (LUM-542).
10
11
  *
11
12
  * This is the AGENT side of the boundary-crossing review loop and the deliberate
12
13
  * inverse of dispositioning: it can only ADD an append-only note for the human
13
- * reviewer to weigh — it never clears the crossing or unblocks Done (a human
14
- * dispositions that, in the web acceptance panel). The crossing must belong to
14
+ * reviewer to weigh — it never clears the crossing or unblocks Done (the user
15
+ * rules on that: `lumo crossing disposition` through the exit-4 confirmation
16
+ * protocol, or the web acceptance panel). The crossing must belong to
15
17
  * the task this session is bound to; the binding is how the target task is
16
18
  * resolved, so run it inside a session attached via `lumo session attach`.
17
19
  */
@@ -39,24 +41,9 @@ async function crossingExplain(crossingId, options = {}) {
39
41
  headers['X-Lumo-Session-Id'] = sessionId;
40
42
  // The crossing is addressed by id, but the route is task-scoped — resolve the
41
43
  // bound task from the session so the server can verify the crossing is on it.
42
- if (!sessionId) {
43
- console.error('Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`.');
44
- return 1;
45
- }
46
- let bound;
47
- try {
48
- const res = await fetch(`${base}/api/sessions/${encodeURIComponent(sessionId)}`, { headers });
49
- bound = res.ok
50
- ? (await res.json())
51
- : null;
52
- }
53
- catch (err) {
54
- const msg = err instanceof Error ? err.message : String(err);
55
- console.error(`Error: could not reach Lumo API (${msg})`);
56
- return 1;
57
- }
58
- if (!bound?.taskIdentifier) {
59
- console.error('Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first.');
44
+ const bound = await (0, bound_task_1.resolveBoundTask)({ base, headers, sessionId });
45
+ if (!bound.ok) {
46
+ console.error(bound.message);
60
47
  return 1;
61
48
  }
62
49
  const taskId = bound.taskIdentifier;
@@ -88,6 +75,8 @@ async function crossingExplain(crossingId, options = {}) {
88
75
  const outcome = (await res.json());
89
76
  process.stdout.write(`✓ Recorded an explanation on crossing ${(0, sanitize_1.sanitizeField)(outcome.crossingId)}.\n` +
90
77
  ' This is an append-only note for the human reviewer — it does not clear ' +
91
- 'the crossing or unblock Done.\n');
78
+ 'the crossing or unblock Done. Once the user rules, record it with ' +
79
+ '`lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope) ' +
80
+ 'or in the web panel.\n');
92
81
  return;
93
82
  }
@@ -43,7 +43,11 @@ function formatSecurityAction(a) {
43
43
  const where = a.line == null ? a.filePath : `${a.filePath}:${a.line}`;
44
44
  const title = (0, sanitize_1.sanitizeField)(a.statement.split(' — ').slice(1).join(' — '));
45
45
  const tag = a.blocking ? 'blocks DONE' : 'advisory';
46
- return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} ${(0, sanitize_1.sanitizeField)(where)} ${title} (PR #${a.prNumber} · ${tag})`;
46
+ // LUM-758 follow-up: a downgraded row is still reported on purpose, so the
47
+ // line has to say WHY it is LOW — otherwise a placeholder the scanner already
48
+ // recognised reads as an unexplained credential.
49
+ const note = (0, sanitize_1.sanitizeField)((0, security_scan_1.secretDowngradeNote)(a.secretDowngrade));
50
+ return `[${a.severity}] ${a.provenance} ${(0, sanitize_1.sanitizeField)(a.ruleId)} — ${(0, sanitize_1.sanitizeField)(where)} — ${title} (PR #${a.prNumber} · ${tag}${note})`;
47
51
  }
48
52
  const SECURITY_HINT = 'Fix the findings and push (a fixed finding disappears on the next scan), or ask a human to disposition them in the web delivery panel — disposition is human-only, you cannot disposition them yourself, and a blocking finding refuses DONE with 409.';
49
53
  /** Render the `[SECURITY]` next-action lines (LUM-737 review): shared by the
@@ -57,40 +61,39 @@ function pushSecurityActions(lines, securityActions) {
57
61
  /** Fail-closed security coverage lines (LUM-737): a failed read and every
58
62
  * open PR without a successful scan are stated, never left silent. */
59
63
  const EXTERNAL_ERROR_TAIL = 200;
60
- const SCAN_STAGE_ORDER = [
61
- 'secrets',
62
- 'external',
63
- 'supplyChain',
64
- 'judge',
65
- 'hunt',
66
- ];
64
+ const SCAN_STAGE_ORDER = security_scan_1.SCAN_STAGE_KEYS;
67
65
  /** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
68
- * secrets RAN · external RAN (2 external findings) · supplyChain RAN
69
- * (1 dependency finding, 1 already on main) · judge RAN · hunt RAN`.
66
+ * Secrets: checked · Code scan: checked (2 external findings) ·
67
+ * Dependencies: checked (1 dependency finding, 1 already on main) ·
68
+ * AI review: checked · Exploit paths: checked`.
70
69
  * Stage segments only for keys present, in
71
70
  * secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
72
- * layer); the `(N external findings)` count only decorates the external
71
+ * layer); the `(N external findings)` count only decorates the Code scan
73
72
  * segment, the dependency count (LUM-738, with its already-on-main share)
74
- * only the supplyChain segment, and each only when N > 0. */
73
+ * only the Dependencies segment, and each only when N > 0.
74
+ * LUM-763: the stage keys are internal — what prints is the display label
75
+ * from `shared/src/security-scan.ts`, the same map the GitHub PR summary
76
+ * and the web panel render from. */
75
77
  function formatScanSummaryLine(s) {
76
78
  const segments = [];
77
79
  for (const key of SCAN_STAGE_ORDER) {
78
80
  const state = s.stages[key];
79
81
  if (!state)
80
82
  continue;
83
+ const head = `${(0, security_scan_1.scanStageLabel)(key)}: ${(0, security_scan_1.scanStageStateLabel)(state)}`;
81
84
  if (key === 'external' && s.externalFindings > 0) {
82
85
  const noun = s.externalFindings === 1 ? 'finding' : 'findings';
83
- segments.push(`external ${state} (${s.externalFindings} external ${noun})`);
86
+ segments.push(`${head} (${s.externalFindings} external ${noun})`);
84
87
  }
85
88
  else if (key === 'supplyChain' && (s.dependencyFindings ?? 0) > 0) {
86
89
  const n = s.dependencyFindings ?? 0;
87
90
  const noun = n === 1 ? 'finding' : 'findings';
88
91
  const persisting = s.persistingFindings ?? 0;
89
92
  const tail = persisting > 0 ? `, ${persisting} already on main` : '';
90
- segments.push(`supplyChain ${state} (${n} dependency ${noun}${tail})`);
93
+ segments.push(`${head} (${n} dependency ${noun}${tail})`);
91
94
  }
92
95
  else {
93
- segments.push(`${key} ${state}`);
96
+ segments.push(head);
94
97
  }
95
98
  }
96
99
  let line = ` PR #${s.prNumber} · scan ${s.status}`;
@@ -108,6 +111,29 @@ function formatScanSummaryLine(s) {
108
111
  }
109
112
  return line;
110
113
  }
114
+ /**
115
+ * LUM-762 — the hunt's per-task read-out, one indented line under its scan:
116
+ * `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done
117
+ * (2 attempts) · t3 race skipped`. Headed by the same display label as the
118
+ * layer's own segment (LUM-763) — the detail line and the line above it
119
+ * must not name the layer two different ways. This is the only place the stored `huntAudit` surfaces, so
120
+ * it is what answers "how long does a healthy batch take" and "which ones
121
+ * never ran" (`skipped`) without paging through deploy logs. Elapsed time is
122
+ * summed over a task's attempts; a skipped task has none to print.
123
+ *
124
+ * LUM-758: a plan may hold two tasks of the same category, so the row is led
125
+ * by its task id. Pre-task-graph audits carry no id — the row then prints
126
+ * exactly as it always did.
127
+ */
128
+ function formatHuntCoverageLine(hunt) {
129
+ const parts = hunt.categories.map(c => {
130
+ const attempts = c.attempts > 1 ? ` (${c.attempts} attempts)` : '';
131
+ const spent = c.stopped === 'skipped' ? '' : `${(c.elapsedMs / 1000).toFixed(1)}s `;
132
+ const id = c.taskId === undefined ? '' : `${c.taskId} `;
133
+ return `${id}${c.category} ${spent}${c.stopped}${attempts}`;
134
+ });
135
+ return ` ${(0, security_scan_1.scanStageLabel)('hunt')} ${hunt.done}/${hunt.total} done · ${parts.join(' · ')}`;
136
+ }
111
137
  function pushSecurityCoverage(lines, data) {
112
138
  if (data.securityFindings === undefined)
113
139
  return; // older server
@@ -118,6 +144,10 @@ function pushSecurityCoverage(lines, data) {
118
144
  // LUM-735: absent `scans` = older server; tolerate silently, no block.
119
145
  for (const s of data.securityFindings.scans ?? []) {
120
146
  lines.push(formatScanSummaryLine(s));
147
+ // LUM-762: absent/null = older server or no usable audit — stay silent.
148
+ if (s.hunt && s.hunt.categories.length > 0) {
149
+ lines.push(formatHuntCoverageLine(s.hunt));
150
+ }
121
151
  }
122
152
  for (const u of data.securityFindings.unconfirmedPrs) {
123
153
  const why = u.reason === 'NONE'
@@ -589,7 +619,7 @@ function pushOpenCrossings(lines, extras) {
589
619
  lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
590
620
  lines.push(` ${formatAttribution(c.attribution)}`);
591
621
  }
592
- lines.push(' Disposition is human-only in the web acceptance panel:');
622
+ lines.push(" Disposition is the user's call: relay each crossing, then `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope; never self-approve), or the web acceptance panel:");
593
623
  if (extras.dispositionUrl) {
594
624
  lines.push(` ${extras.dispositionUrl}`);
595
625
  }
@@ -66,6 +66,7 @@ const priority_1 = require("./commands/priority");
66
66
  const criteria_audit_1 = require("./commands/criteria-audit");
67
67
  const verify_1 = require("./commands/verify");
68
68
  const crossing_explain_1 = require("./commands/crossing-explain");
69
+ const crossing_disposition_1 = require("./commands/crossing-disposition");
69
70
  const outcome_1 = require("./commands/outcome");
70
71
  const task_context_1 = require("./commands/task-context");
71
72
  const task_create_1 = require("./commands/task-create");
@@ -274,12 +275,23 @@ program
274
275
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
275
276
  const crossing = program
276
277
  .command('crossing')
277
- .description('Inspect and annotate boundary crossings');
278
+ .description('Inspect, annotate and (with user approval) disposition boundary crossings');
278
279
  crossing
279
280
  .command('explain <id>')
280
281
  .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.')
281
282
  .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
282
283
  .action(wrap((id, options) => (0, crossing_explain_1.crossingExplain)(id, options)));
284
+ crossing
285
+ .command('disposition <id>')
286
+ .description("Rule on a boundary crossing from the terminal (LUM-769) through a three-step confirmation handshake the server enforces: (1) no step flag — prints the crossing (severity, detail, agent explanations) as an envelope with a stage-1 read receipt, exit 4, writes nothing; (2) --confirm-read --receipt <r1> after the user confirms they READ it — returns the stage-2 receipt and a second envelope for the ruling, exit 4; (3) --confirm --receipt <r2> after the user approves the ruling — writes. Confirming before the read is acknowledged is refused and told to confirm the read first. Relay each envelope; never supply a step flag yourself. Either ruling clears the crossing's block on DONE and is audited as a CLI-channel disposition. Reverting to OPEN and repository suppression rules stay web-only. Targets a crossing on the session-bound task unless --task is given.")
287
+ .option('--false-positive', 'Rule the crossing a false positive (not a real issue)')
288
+ .option('--confirmed', 'Rule the crossing confirmed (a real issue, acknowledged)')
289
+ .option('--note <text>', 'Optional disposition note recorded with the ruling')
290
+ .option('--task <identifier>', 'Task the crossing belongs to (defaults to the session-bound task)')
291
+ .option('--receipt <token>', "Read receipt from the previous step's envelope (its confirmCommand carries it); required with --confirm-read (stage 1 receipt) and --confirm (stage 2 receipt), verified server-side")
292
+ .option('--confirm-read', 'Step 2: the user confirmed they have read the envelope (never add this on your own initiative). Returns the ruling envelope; writes nothing')
293
+ .option('--confirm', 'Step 3: the user approved the ruling (never add this on your own initiative). Refused unless the read was confirmed first')
294
+ .action(wrap((id, options) => (0, crossing_disposition_1.crossingDisposition)(id, options)));
283
295
  const outcome = program
284
296
  .command('outcome')
285
297
  .description('Read the post-hoc outcome well (LUM-598)');
@@ -13,10 +13,14 @@ exports.emitBlocked = emitBlocked;
13
13
  * The confirmation protocol (confirmation.ts, exit 4) covers mutations the
14
14
  * USER can approve. Some refusals are different in kind: the server's DONE
15
15
  * gates — an unresolved send-back, an undispositioned boundary crossing, a
16
- * blocking security finding — are verdicts on the agent's own work, and by
17
- * design there is no CLI path to clear them (the interested party may not
18
- * adjudicate itself). Echoing the 409 text and exiting 1 left the agent
19
- * grepping prose to learn that. Instead the CLI now:
16
+ * blocking security finding — are verdicts on the agent's own work, and the
17
+ * agent may not adjudicate itself: nothing on `task update` clears them.
18
+ * Since LUM-769 a boundary crossing does have a terminal path, but it is the
19
+ * USER's `lumo crossing disposition` walks the same exit-4 protocol (the
20
+ * agent relays the crossing, the user rules, the agent re-runs with the
21
+ * approval flag); send-backs and security findings stay human-side / web.
22
+ * Echoing the 409 text and exiting 1 left the agent grepping prose to learn
23
+ * that. Instead the CLI now:
20
24
  *
21
25
  * 1. recognises the gate refusal (`isDoneGateRefusal`);
22
26
  * 2. re-reads the existing read models — `GET …/status` for send-backs and
@@ -100,9 +104,10 @@ async function collectDoneBlockers(args) {
100
104
  return { blockers, unconfirmed };
101
105
  }
102
106
  /**
103
- * One line per blocker kind present. Every line names a human-side path or
104
- * the append-only `crossing explain` — never a flag or command that would let
105
- * the agent clear its own blocker.
107
+ * One line per blocker kind present. Every line names a human-side path, the
108
+ * append-only `crossing explain`, or the user-approved `crossing disposition`
109
+ * (exit-4 protocol) never a ready-to-run flag that would let the agent
110
+ * clear its own blocker on its own initiative.
106
111
  */
107
112
  function buildRemediation(blockers, unconfirmed, ctx) {
108
113
  const kinds = new Set(blockers.map(b => b.kind));
@@ -114,7 +119,7 @@ function buildRemediation(blockers, unconfirmed, ctx) {
114
119
  const ids = blockers
115
120
  .filter(b => b.kind === 'BOUNDARY_CROSSING')
116
121
  .map(b => b.id);
117
- lines.push(`BOUNDARY_CROSSING: leave a rationale with \`lumo crossing explain ${ids[0]} --note "…"\` (append-only; it does not clear anything). A human dispositions each crossing (false positive / confirmed) at ${ctx.dispositionUrl}`);
122
+ lines.push(`BOUNDARY_CROSSING: leave a rationale with \`lumo crossing explain ${ids[0]} --note "…"\` (append-only; it does not clear anything), then relay each crossing to the user. Once they rule, record it with \`lumo crossing disposition ${ids[0]} --false-positive\` or \`--confirmed\` it prints an exit-4 envelope for the user to approve; never self-approve. The web panel also works: ${ctx.dispositionUrl}`);
118
123
  }
119
124
  if (kinds.has('SECURITY_FINDING')) {
120
125
  lines.push('SECURITY_FINDING: fix and push — a fixed fingerprint disappears from the next scan — or a human marks it false positive / accepted risk in the web delivery panel.');
@@ -167,7 +172,7 @@ function renderBlocked(err, isTTY) {
167
172
  '',
168
173
  'What can move it:',
169
174
  ...err.remediation.map(r => ` - ${r}`),
170
- ` Disposition (human-only): ${err.dispositionUrl}`,
175
+ ` Web disposition panel: ${err.dispositionUrl}`,
171
176
  ];
172
177
  return lines.join('\n') + '\n';
173
178
  }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBoundTask = resolveBoundTask;
4
+ async function resolveBoundTask(args) {
5
+ const explicit = args.explicit?.trim();
6
+ if (explicit)
7
+ return { ok: true, taskIdentifier: explicit };
8
+ if (!args.sessionId) {
9
+ return {
10
+ ok: false,
11
+ message: 'Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`, or pass --task <LUM-N>.',
12
+ };
13
+ }
14
+ let bound;
15
+ try {
16
+ const res = await fetch(`${args.base}/api/sessions/${encodeURIComponent(args.sessionId)}`, { headers: args.headers });
17
+ bound = res.ok
18
+ ? (await res.json())
19
+ : null;
20
+ }
21
+ catch (err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ return { ok: false, message: `Error: could not reach Lumo API (${msg})` };
24
+ }
25
+ if (!bound?.taskIdentifier) {
26
+ return {
27
+ ok: false,
28
+ message: 'Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first, or pass --task <LUM-N>.',
29
+ };
30
+ }
31
+ return { ok: true, taskIdentifier: bound.taskIdentifier };
32
+ }
@@ -42,27 +42,54 @@ function shellQuote(arg) {
42
42
  return arg;
43
43
  return `'${arg.replace(/'/g, `'\\''`)}'`;
44
44
  }
45
+ /** Flags that belong to a previous step of a handshake and must not carry
46
+ * over into the next step's command: the step flags themselves and a prior
47
+ * `--receipt <token>` pair (the next envelope brings its own receipt). */
48
+ const STEP_FLAGS = new Set(['--confirm', '--confirm-read']);
49
+ function stripStepFlags(argv) {
50
+ const out = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (STEP_FLAGS.has(a))
54
+ continue;
55
+ if (a === '--receipt') {
56
+ i++; // drop its value too
57
+ continue;
58
+ }
59
+ if (a.startsWith('--receipt='))
60
+ continue;
61
+ out.push(a);
62
+ }
63
+ return out;
64
+ }
45
65
  /**
46
- * Rebuild the invocation the user ran, plus `--confirm` (and any
47
- * `extraFlags`). Flags already present in argv are never duplicated, and
48
- * `--confirm` always lands last so the command reads as "…, approved".
66
+ * Rebuild the invocation the user ran, plus the approval flag (default
67
+ * `--confirm`) and any `extraFlags`. Step flags and a prior `--receipt` from
68
+ * an earlier handshake step are dropped first, flags already present in argv
69
+ * are never duplicated, and the approval flag always lands last so the
70
+ * command reads as "…, approved".
49
71
  */
50
- function buildConfirmCommand(argv = process.argv.slice(2), extraFlags = []) {
51
- const kept = argv.filter(a => a !== '--confirm');
72
+ function buildConfirmCommand(argv = process.argv.slice(2), extraFlags = [], confirmFlag = '--confirm') {
73
+ const kept = stripStepFlags(argv);
52
74
  const parts = ['lumo', ...kept.map(shellQuote)];
53
75
  for (const flag of extraFlags) {
54
76
  if (!kept.includes(flag))
55
77
  parts.push(flag);
56
78
  }
57
- parts.push('--confirm');
79
+ parts.push(confirmFlag);
58
80
  return parts.join(' ');
59
81
  }
60
82
  function buildEnvelope(input) {
83
+ const extraFlags = [
84
+ ...(input.extraFlags ?? []),
85
+ ...(input.receipt ? ['--receipt', input.receipt.token] : []),
86
+ ];
61
87
  return {
62
88
  status: 'confirmation_required',
63
89
  command: input.command,
64
90
  changes: input.changes,
65
- confirmCommand: buildConfirmCommand(input.argv, input.extraFlags),
91
+ confirmCommand: buildConfirmCommand(input.argv, extraFlags, input.confirmFlag ?? '--confirm'),
92
+ ...(input.receipt ? { receipt: input.receipt } : {}),
66
93
  };
67
94
  }
68
95
  /** JSON for pipes; the same fields as readable text for a terminal. */
@@ -72,7 +99,10 @@ function renderConfirmation(envelope, isTTY) {
72
99
  const lines = [
73
100
  `Confirmation required — \`lumo ${envelope.command}\` was not run.`,
74
101
  ...envelope.changes.map(c => ` - ${c}`),
75
- 'Re-run with --confirm once the user has approved:',
102
+ ...(envelope.receipt
103
+ ? [`Read receipt issued (valid until ${envelope.receipt.expiresAt}).`]
104
+ : []),
105
+ `Re-run with ${envelope.confirmCommand.endsWith('--confirm-read') ? '--confirm-read' : '--confirm'} once the user has approved:`,
76
106
  ` ${envelope.confirmCommand}`,
77
107
  ];
78
108
  return lines.join('\n') + '\n';
@@ -28,9 +28,9 @@ function normalizeSeverity(s) {
28
28
  * first, via the EXISTING LUM-435 read endpoint — `GET …/boundary-crossings`
29
29
  * returns every crossing (open and dispositioned); we keep only the
30
30
  * undispositioned ones (`disposition == null`). This is the **read/awareness**
31
- * half of the acceptance loop: there is no new query and, by construction, no
32
- * way to clear a crossing disposition stays web + human-only
33
- * (LUM-426/435/422).
31
+ * half of the acceptance loop: there is no new query and this helper cannot
32
+ * clear anything a ruling goes through `lumo crossing disposition` (exit-4
33
+ * user approval, LUM-769) or the web panel.
34
34
  *
35
35
  * Fails *closed*, not open (LUM-480): any transport / non-ok HTTP / parse
36
36
  * failure returns `{ status: 'error', reason }` so the caller can say "check
@@ -75,9 +75,9 @@ async function fetchOpenCrossings(apiUrl, token, taskIdentifier) {
75
75
  return { status: 'ok', crossings };
76
76
  }
77
77
  /**
78
- * The web deep link where a HUMAN dispositions crossings. Disposition is
79
- * web-only and human-only (LUM-426/435/422); the terminal only ever points
80
- * here, it never clears anything itself. Built from the workspace slug +
78
+ * The web deep link where a human dispositions crossings in the panel — the
79
+ * counterpart of `lumo crossing disposition` (LUM-769); the awareness surfaces
80
+ * (task status, DONE_BLOCKED) point here. Built from the workspace slug +
81
81
  * identifier alone (the `/my-tasks/<id>` route needs no project slug), so no
82
82
  * extra fetch is required.
83
83
  */
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.EXTERNAL_ERROR_PREFIX = void 0;
3
+ exports.SECRET_DOWNGRADE_LABELS = exports.SCAN_STAGE_STATE_LABELS = exports.SCAN_STAGE_LABELS = exports.SCAN_STAGE_STATES = exports.SCAN_STAGE_KEYS = exports.EXTERNAL_ERROR_PREFIX = void 0;
4
4
  exports.externalFailureReason = externalFailureReason;
5
+ exports.scanStageLabel = scanStageLabel;
6
+ exports.scanStageStateLabel = scanStageStateLabel;
7
+ exports.secretDowngradeNote = secretDowngradeNote;
5
8
  /**
6
9
  * PR security scan — the one `PrSecurityScan.error` shape that may be shown
7
10
  * as an external-scanner reason (spec P8). Shared by the web panel, the PR
@@ -27,3 +30,96 @@ function externalFailureReason(error) {
27
30
  const reason = error.slice(exports.EXTERNAL_ERROR_PREFIX.length);
28
31
  return reason.length > 0 ? reason : null;
29
32
  }
33
+ /**
34
+ * LUM-763 — the scan's five layers, as stored.
35
+ *
36
+ * These keys are a persisted contract, not a display choice: they sit in
37
+ * `PrSecurityScan.stages` on every historical row, travel over the
38
+ * task-status API, and are read by CLIs older than this change. They do not
39
+ * get renamed; only what a reader is shown does.
40
+ */
41
+ exports.SCAN_STAGE_KEYS = [
42
+ 'secrets',
43
+ 'external',
44
+ 'supplyChain',
45
+ 'judge',
46
+ 'hunt',
47
+ ];
48
+ /** The states a layer can be in. `ScanStageState` in `lib/security-scan/types.ts` derives from this. */
49
+ exports.SCAN_STAGE_STATES = [
50
+ 'RAN',
51
+ 'FAILED',
52
+ 'SKIPPED',
53
+ 'NOT_CONFIGURED',
54
+ 'PENDING',
55
+ 'PARTIAL',
56
+ 'SUPERSEDED',
57
+ ];
58
+ /**
59
+ * What each layer is called where a person reads it — the PR comment and
60
+ * `lumo task status`, which share this map so the two cannot drift (the web
61
+ * panel renders the same wording through `securityScan.stage.*` in i18n).
62
+ *
63
+ * Named for what the layer checks, never for who runs it or which model it
64
+ * uses: `external` was accurate only while the scanners were the customer's,
65
+ * and stopped being true the moment Lumo started running them itself.
66
+ */
67
+ exports.SCAN_STAGE_LABELS = {
68
+ secrets: 'Secrets',
69
+ external: 'Code scan',
70
+ supplyChain: 'Dependencies',
71
+ judge: 'AI review',
72
+ hunt: 'Exploit paths',
73
+ };
74
+ /**
75
+ * What each state is called. `RAN` deliberately reads as "checked", not as a
76
+ * tick or "clean": it means the layer completed, and a layer that completed
77
+ * may well have found something — the findings are counted on their own
78
+ * lines. Only the states that leave the scan incomplete carry a glyph, so
79
+ * attention is drawn to "do not trust this line" and never to "this is fine"
80
+ * (spec P9 — a security signal never claims safety it did not establish).
81
+ */
82
+ exports.SCAN_STAGE_STATE_LABELS = {
83
+ RAN: 'checked',
84
+ FAILED: '✗ failed',
85
+ SKIPPED: 'skipped',
86
+ NOT_CONFIGURED: 'off',
87
+ PENDING: 'scanning',
88
+ PARTIAL: '⚠ incomplete',
89
+ SUPERSEDED: '⚠ superseded',
90
+ };
91
+ /** The layer's display name; an unrecognised key renders as itself rather than vanishing. */
92
+ function scanStageLabel(key) {
93
+ return exports.SCAN_STAGE_LABELS[key] ?? key;
94
+ }
95
+ /** The state's display name; an unrecognised state renders as itself. */
96
+ function scanStageStateLabel(state) {
97
+ return exports.SCAN_STAGE_STATE_LABELS[state] ?? state;
98
+ }
99
+ /**
100
+ * LUM-758 follow-up — why a deterministic secret hit was lowered to LOW.
101
+ *
102
+ * Downgrading is not suppression: the row is still reported, deliberately, so
103
+ * that a human decides. But a reported row with no stated cause reads as an
104
+ * unexplained credential, which is how a test fixture the scanner *already
105
+ * recognised* as a placeholder ends up looking like an open risk. These say
106
+ * which rule fired, in the same words on every surface.
107
+ *
108
+ * Phrased as a property of the value or the path, never as a verdict: the
109
+ * scanner knows the string looks like a placeholder, not that it is harmless.
110
+ */
111
+ exports.SECRET_DOWNGRADE_LABELS = {
112
+ PLACEHOLDER_VALUE: 'placeholder value',
113
+ DOWNGRADED_PATH: 'test/fixture/doc path',
114
+ };
115
+ /**
116
+ * The parenthesised note appended to a rendered finding line, or `''` when the
117
+ * row carries no downgrade — an unrecognised reason renders as itself rather
118
+ * than vanishing, so a value added server-side is never silently dropped by an
119
+ * older CLI.
120
+ */
121
+ function secretDowngradeNote(reason) {
122
+ if (!reason)
123
+ return '';
124
+ return ` — ${exports.SECRET_DOWNGRADE_LABELS[reason] ?? reason}, downgraded`;
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",