@lumoai/cli 1.33.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
31
31
  | `doc*` | [references/docs.md](references/docs.md) |
32
32
  | `sprint*` | [references/sprints.md](references/sprints.md) |
33
33
  | `task/project memory`, `memory promote/rm` | [references/memory.md](references/memory.md) |
34
- | `session attach/status/detach/wrap`, git-suggest on start, Layer-2 review | [references/sessions.md](references/sessions.md) |
34
+ | `session attach/status/wrap`, git-suggest on start, Layer-2 review | [references/sessions.md](references/sessions.md) |
35
35
  | `worktree add/rm/list` (local dev tooling) | [references/worktree.md](references/worktree.md) |
36
36
 
37
37
  ## Command catalog
@@ -73,13 +73,13 @@ The command catalog below is a **map**: it lists every command grouped by domain
73
73
 
74
74
  **Acceptance criteria (contract)** — see [criteria.md](references/criteria.md)
75
75
 
76
- - `lumo task criteria set <task> --file <criteria.json> [--human] [--cause <tag>]` — submit the whole contract: default = initial agent draft (AGENT_DRAFT, locked once submitted); `--human` = a HUMAN_EDIT revision transcribed from the conversation (desired final list; items with `id` keep/update, missing ones are deleted); `--cause` (with `--human`) annotates why the contract drifted: `NEW_INFO | SCOPE_CHANGE | DRAFT_BLIND_SPOT | GRANULARITY | OTHER`
76
+ - `lumo task criteria set <task> --file <criteria.json> [--human] [--cause <tag>]` — submit the whole contract: default = agent draft (AGENT_DRAFT, editable until DONE — full-group replace while never-left-TODO; identity-preserving diff with CRITERION_CHANGED drift trail once work has started; 409 only when task is DONE); `--human` = a HUMAN_EDIT revision transcribed from the conversation (desired final list; items with `id` keep/update, missing ones are deleted; allowed in every non-DONE stage); `--cause` (with `--human`) annotates why the contract drifted: `NEW_INFO | SCOPE_CHANGE | DRAFT_BLIND_SPOT | GRANULARITY | OTHER`
77
77
  - `lumo task criteria list <task>` — print the contract (id, MACHINE/HUMAN, provenance source@round, checkpointer)
78
78
 
79
79
  **Verification (machine acceptance loop)** — see [verify.md](references/verify.md)
80
80
 
81
81
  - `lumo verify [task] [--timeout <seconds>]` — run every MACHINE criterion's checkpointer locally, report one structured PASS/FAIL verdict per criterion to the server, print next actions. Defaults to the session-bound task. Round cap 3: an all-pass round moves the task to IN_REVIEW (agent stops there); a round-3 fail escalates to a human (stop retrying). **Run this before claiming a task is done.**
82
- - `lumo task status [task] [--json]` — read-only acceptance self-check (no LLM, milliseconds): the contract with each criterion's latest verdict (REVIEW_ADDED provenance visible), verification history, current round, last round's failure reasons, and `nextActions` = the unmet criteria (the declarative "what's next" — no separate plan). Defaults to the session-bound task; `--json` emits a versioned payload (`version` field). **Run it first when resuming a task in a new session or after a verification round was rejected.**
82
+ - `lumo task status [task] [--json]` — read-only acceptance self-check (no LLM, milliseconds): the contract with each criterion's latest verdict (REVIEW_ADDED provenance visible), verification history, current round, last round's failure reasons, `nextActions` = the unmet criteria (the declarative "what's next" — no separate plan), and any OPEN (undispositioned) boundary crossings (count + per crossing category/severity/detail + a read-only attribution line `↳ by model=…·agent=…·session=…` naming who/what crossed, `unknown` when unresolved — LUM-469; `--json` adds an `openCrossings` field, each entry carrying an `attribution` object) — read-only awareness, disposition stays web + human-only (LUM-448). The crossings check fails closed (LUM-480): if the read errors, the block prints `⚠ Boundary-crossing check failed` instead of staying silent, and `--json` sets `openCrossings: null` (distinct from `[]` = a successful read with zero open — treat `null` as "could not confirm", not "safe"). Defaults to the session-bound task; `--json` emits a versioned payload (`version` field). **Run it first when resuming a task in a new session or after a verification round was rejected.**
83
83
  - `lumo verdict [task] --pass | --pass-with-followup | --fail` — acceptance verdicts (LUM-422). `--pass` / `--pass-with-followup` open the browser to the human verdict bar focused on the passing action (a deep link — **records nothing**; a passing data row is only ever a human's own click). `--fail --reason <enum> [--note <text>] [--criterion <id>…]` records an **AGENT send-back** (verifierType=AGENT, verdict hard-coded FAIL) and bounces the task to IN_PROGRESS. Defaults to the session-bound task. **An unresolved send-back (machine/AGENT/human FAIL) blocks the agent/CLI DONE transition with 409** — clear it (re-verify) before `task update --status done`.
84
84
 
85
85
  **Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
@@ -104,6 +104,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
104
104
  - `lumo doc patch <doc> --section "<heading>" --content/--file/stdin [--if-revision N] [--allow-shrink]` — replace ONLY that section (heading line included); every byte outside it is untouched; concurrent edits 409 instead of clobbering; the structure guard 422s a replacement that drops the section's tables/rows/headings unless `--allow-shrink` (appends are never guarded)
105
105
  - `lumo doc append <doc> [--section "<heading>"] --content/--file/stdin [--if-revision N]` — append at section end (or doc end without `--section`); pure insertion, no existing byte modified — the safest write for running logs/ledgers
106
106
  - `lumo doc diff <doc> --file <local.md>` — compare the server-side markdown source against a local file (exit 0 identical, 1 with unified diff)
107
+ - `lumo doc rebuild-source <doc> [--allow-shrink] [--force] [--if-revision N]` — regenerate `sourceMarkdown` from the stored HTML body with a lossless serializer (tables/rows/headings round-trip), re-enabling raw/diff/section/append on a source-less doc; structure-guarded (422 on any shrink, LUM-410 口径), backfills the source only; 409 if a source already exists unless `--force`. Recovery path when raw/section/patch errors "no stored source" (LUM-446)
107
108
  - `lumo doc move` — reparent under a parent / to root
108
109
  - `lumo doc bind/unbind <doc> <task>` — task linkage
109
110
  - `lumo doc share/unshare/share-list` — member sharing
@@ -123,9 +124,9 @@ The command catalog below is a **map**: it lists every command grouped by domain
123
124
 
124
125
  **Sessions** — see [sessions.md](references/sessions.md)
125
126
 
126
- - `lumo session attach <id>` — bind this session to a task (then run `task context`)
127
- - `lumo session status` / `lumo session detach` — show / clear binding
128
- - `lumo session wrap [--yes] [--dry-run] [--used <indices>]` — end-of-session panel: progress comment + memory review + fragment-usage vote (`--used`, LUM-300) + blocked-tag prompt. Usage is now also audited automatically when a task reaches DONE (evidence-gated, true-only — confident fragments marked used, the rest left NULL); `session wrap --used` remains the manual override and takes precedence for a session.
127
+ - `lumo session attach <id>` — bind this session to a task (then run `task context`). **Lifetime lock**: re-attaching to the same task is a no-op; attaching to a _different_ task is refused with 409 — start a new Claude Code session instead. No `--force`, no `session detach`.
128
+ - `lumo session status` — show current binding
129
+ - `lumo session wrap [--yes] [--dry-run] [--used <indices>]` — end-of-session panel: progress comment + memory review + fragment-usage vote (`--used`, LUM-300) + blocked-tag prompt, then a read-only reminder when the bound task has ≥1 OPEN boundary crossing still undispositioned (silent only on a genuine empty read — no wrap-up noise; a crossings-check failure prints a "could not confirm" warning instead of staying silent, LUM-480; pointer is web + human-only, LUM-448). Usage is now also audited automatically when a task reaches DONE (evidence-gated, true-only — confident fragments marked used, the rest left NULL); `session wrap --used` remains the manual override and takes precedence for a session.
129
130
  - Git-suggest at session start (suggests `session attach`, never auto-binds) + Layer-2 project-memory review — see the reference
130
131
 
131
132
  **Worktrees (local dev tooling)** — see [worktree.md](references/worktree.md)
@@ -139,6 +140,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
139
140
  Measured from real agent sessions (LUM-392) — don't guess these:
140
141
 
141
142
  - No `lumo session start` — binding is `lumo session attach <LUM-N>`
143
+ - No `lumo session detach` — the session↔task binding is a lifetime lock (LUM-459); to work on a different task, start a new Claude Code session
142
144
  - No `lumo task delete` — tasks can't be deleted from the CLI (web UI only)
143
145
  - No `lumo task artifact edit` — it's `lumo task artifact update`
144
146
  - No `lumo auth status` — identity check is `lumo whoami`
@@ -22,12 +22,32 @@ contract, you MUST draft and submit criteria before starting implementation:
22
22
  merge related checks instead). **Scale the count down for small tasks** —
23
23
  see "Scale the contract to the task size" below.
24
24
  3. Submit with `lumo task criteria set <task> --file <criteria.json>`.
25
- Submission **locks the contract for agent edits** — get it right before
26
- submitting, because afterwards only human revisions (`--human`) can change it.
27
25
 
28
- Once you (the agent) have started work, you can NOT change your own contract.
29
- That's by design: the contract is what your work gets judged against, not a
30
- to-do list you trim to fit what you built.
26
+ **The contract is editable until DONE but changes after work starts leave a
27
+ recorded drift trail.** The behaviour changes at two thresholds tracked by
28
+ `Task.workStartedAt` (set the first time the task leaves TODO; never resets even
29
+ if the task bounces back to TODO):
30
+
31
+ - **Work has never started** (`workStartedAt` unset, task still in its initial
32
+ TODO phase): a `criteria set` resubmit replaces the whole contract freely —
33
+ full-group replace, recorded as `AGENT_DRAFT@round0`. Iterate as much as
34
+ needed during planning.
35
+ - **Work has started** (the task left TODO at least once): a `criteria set`
36
+ resubmit is **still allowed** and applies an **identity-preserving diff** — the
37
+ CLI matches submitted items to existing criteria by id (attaching ids
38
+ automatically by exact-statement match) and records each add, update, or delete
39
+ as a `CRITERION_CHANGED` drift event at the current round. **No 409.** The
40
+ intent: the contract can be sharpened as understanding grows, but every change
41
+ is traceable.
42
+ - **Task is DONE**: the contract locks — a `criteria set` resubmit is rejected
43
+ with 409. Reopen the task (move it back to in_progress) to change criteria, or
44
+ use `--human`.
45
+
46
+ `--human` revisions are allowed in every non-DONE stage. One deletion rule holds
47
+ across all stages: a criterion that already has verification runs is
48
+ **soft-deleted** (kept for audit, runs preserved) rather than hard-deleted; a
49
+ run-free criterion is hard-deleted. Either way, reword rather than delete when
50
+ possible.
31
51
 
32
52
  ## Scale the contract to the task size
33
53
 
@@ -71,8 +91,70 @@ must track the size of the work — it is not a fixed ritual:
71
91
  `npx tsx scripts/jest-t.ts '<exact test name>' [test file path]` instead:
72
92
  it fails unless ≥1 matching test ran and passed. The optional path scopes
73
93
  the run to one file (much faster than name-filtering the whole suite).
94
+ - **A checkpointer must be instance-independent — never self-comparing.** It
95
+ is stored once and re-run for the life of the task, so it must not depend on
96
+ a baseline that shifts under it. The trap is a `vs origin/main`
97
+ self-comparison ("current file is strictly smaller than origin/main"): it
98
+ passes pre-merge, but the moment the change merges, every branch cut from
99
+ main has `current == base` and the check fails forever (LUM-433 c1, fixed in
100
+ #528). Anchor to a **fixed, absolute target** instead — a literal budget
101
+ (`byteCount ≤ 9500`), a committed fixture, or a stable named test run through
102
+ the zero-match guard. A jest-by-name checkpointer likewise rots when its test
103
+ is renamed or deleted — often by _another_ task's work, so the stored PASS
104
+ goes stale invisibly. Keep test names stable; `scripts/checkpointer-drift.ts`
105
+ statically sweeps stored contracts for these zero-match checkpointers
106
+ (`npx tsx scripts/checkpointer-drift.ts <LUM-N> | --mine`, detection-only).
74
107
  - `evidenceRequired: true` marks criteria whose verdict must point at proof
75
108
  (a MACHINE PASS always requires evidence regardless of this flag).
109
+ - **Edited a MACHINE checkpointer mid-task? Re-run `lumo verify`.** A prior PASS
110
+ was recorded against the old command — once you swap the checkpointer (or
111
+ reword the criterion) that pass goes stale: `lumo task status` / the acceptance
112
+ tab flag it `⚠ pre-edit version` (LUM-457). The stale pass still counts as met
113
+ (render-only, doesn't block DONE), but re-verify so the green reflects the
114
+ current check.
115
+
116
+ ### judgeSteps — agent-drafted judging steps for HUMAN criteria (LUM-465)
117
+
118
+ A HUMAN criterion is judged by a person, not a checkpointer — so don't hand them
119
+ a bare assertion ("the copy reads naturally") and make them reverse-engineer
120
+ what to do. Attach **`judgeSteps`**: short, human-readable instructions the
121
+ adjudication card renders verbatim (light markdown, URLs are made clickable).
122
+ Structured labour is yours; the human just follows the steps.
123
+
124
+ **Shape — 1–3 steps, always in this order:**
125
+
126
+ 1. **Where to look** — the exact link or path (a task URL, a tab, a file). Paste
127
+ the real URL; the card/`lumo task status` render it clickable.
128
+ 2. **What to do** — the concrete action ("open the acceptance tab", "read the
129
+ `judgeSteps` block aloud", "resize to mobile width").
130
+ 3. **✓ pass / ✗ send-back** — the decision rule, both directions, so the verdict
131
+ isn't a coin-flip ("pass if every URL opens; send back if any step is vague").
132
+
133
+ **One criterion = one judgment point.** If a HUMAN criterion bundles two checks,
134
+ split it — each half gets its own steps. The canonical miss is LUM-397's "additive
135
+ schema **and** self-credit not implemented": those are two assertions; the first
136
+ is even machine-able (below), the second is a separate human read.
137
+
138
+ **Machine-able → make it MACHINE, don't burn human attention.** Before writing
139
+ `judgeSteps`, ask whether a checkpointer could decide it. "The migration is purely
140
+ additive" → `grep` the `migration.sql` for `DROP`/destructive DDL → MACHINE. "No
141
+ file under `prisma/migrations/` was deleted" → a `git diff` probe → MACHINE.
142
+ Reserve HUMAN + `judgeSteps` for genuine taste/feel/fidelity judgments a check
143
+ can't make.
144
+
145
+ `judgeSteps` is **optional and non-blocking** — a HUMAN criterion without it still
146
+ submits, but the server returns a soft warning (same channel as the 3–7 count
147
+ warning) and the card shows a "no judging guidance" fallback. It is part of the
148
+ contract: editing it drifts like any other field (`CRITERION_CHANGED` before/after
149
+ carries it), and it survives a statement-only web edit.
150
+
151
+ ```json
152
+ {
153
+ "statement": "The acceptance tab reads as plain operational instructions, not agent-voice assertions",
154
+ "verifierType": "HUMAN",
155
+ "judgeSteps": "Open the task's Acceptance tab (the IN_REVIEW card). For each HUMAN criterion, read its 'How to judge' block. Pass if each one names where to look, what to do, and what counts as pass vs send-back; send back if any reads as a bare assertion with no steps."
156
+ }
157
+ ```
76
158
 
77
159
  ### Invariant (negative-assertion) criteria
78
160
 
@@ -155,7 +237,8 @@ it shouldn't_.
155
237
  },
156
238
  {
157
239
  "statement": "The criteria section reads naturally as part of the task statement",
158
- "verifierType": "HUMAN"
240
+ "verifierType": "HUMAN",
241
+ "judgeSteps": "Open the task's Acceptance tab. Read the contract top to bottom. Pass if it scans as one coherent definition of done; send back if a criterion reads as boilerplate or contradicts another."
159
242
  },
160
243
  {
161
244
  "statement": "Session-start injection shows the contract ahead of memory",
@@ -168,16 +251,24 @@ it shouldn't_.
168
251
 
169
252
  Fields: `statement` (required, ≤2000 chars), `verifierType` (`"MACHINE"` |
170
253
  `"HUMAN"`), `checkpointer` (required for MACHINE), `evidenceRequired`
171
- (optional, default false), `id` (only in `--human` revisions see below).
254
+ (optional, default false), `judgeSteps` (optional, ≤2000 charsagent-drafted
255
+ human-judging steps for a HUMAN criterion; see "judgeSteps" above), `id` (only
256
+ in `--human` revisions — see below).
172
257
 
173
258
  ## Commands
174
259
 
175
260
  ### `lumo task criteria set <task> --file <criteria.json>`
176
261
 
177
- Initial agent draft. Creates the whole contract as `AGENT_DRAFT` at round 0.
178
- Rejected with 409 once **any** criteria exist on the task the agent lock.
179
- Echoes the stored criteria (with ids) plus the 3–7 soft-cap warning if
180
- outside range.
262
+ Agent draft, recorded as `AGENT_DRAFT` at round 0. **Editable until DONE:**
263
+ while `workStartedAt` is unset (task never left TODO) a resubmit replaces the
264
+ whole contract (full-group baseline). Once work has started an agent resubmit
265
+ applies an **identity-preserving diff** — matched by id, with each add/update/
266
+ delete recorded as a `CRITERION_CHANGED` drift event; no 409. The contract locks
267
+ (409) only once the task is **DONE** — reopen it to change criteria, or use
268
+ `--human`. A criterion that already has verification runs is soft-deleted (audit
269
+ trail preserved) rather than hard-deleted; run-free criteria are hard-deleted.
270
+ Echoes the stored criteria (with ids) plus the 3–7 soft-cap warning if outside
271
+ range.
181
272
 
182
273
  ```bash
183
274
  lumo task criteria set LUM-42 --file criteria-lum42.json
@@ -134,7 +134,7 @@ lumo doc show cmd_xxx --section "D 状态表" > sec.md # one section only (revi
134
134
  **`--raw` (LUM-408)** prints the byte-identical markdown source of the last markdown upload — no header, no trailing newline added. The server stores the raw markdown (`sourceMarkdown`) alongside the rendered HTML on every markdown write (`doc create/update --content/--file/stdin`, gdoc import/sync), so `--raw` output IS a legal edit base: `doc show --raw > base.md`, edit, `doc update --file base.md` round-trips losslessly.
135
135
 
136
136
  - A web-editor (HTML-direct) edit or revision restore **invalidates** the stored source — the doc's markdown source is gone until the next markdown upload.
137
- - When no source is stored (legacy doc or after an HTML edit), `--raw` **fails with exit 1 and a rebuild hint** — it never silently falls back to the lossy HTML→markdown reverse render (that fallback flattened tables: LUM-349). Rebuild flow: `doc show` (rendered) → reconstruct the markdown faithfully → `doc update --file rebuilt.md` `--raw` works from then on.
137
+ - When no source is stored (legacy doc or after an HTML edit), `--raw` **fails with exit 1 and a rebuild hint** — it never silently falls back to the lossy HTML→markdown reverse render (that fallback flattened tables: LUM-349). Rebuild flow (LUM-446): run **`lumo doc rebuild-source <doc>`** — it regenerates the source from the stored HTML with a lossless serializer (tables round-trip) and a structure guard, so `--raw` works from then on. (The old manual flow — `doc show` rendered → hand-reconstruct → `doc update --file` flattened tables and is superseded.)
138
138
  - Raw output is verbatim (unsanitized) by design — redirect it to a file rather than reading it in a terminal when the doc's provenance is uncertain.
139
139
 
140
140
  Note: the markdown rendered by **default-mode** `doc show` is still best-effort (tables flatten). Round-trip via `doc show > tmp.md && doc update --file tmp.md` is NOT a no-op — use `--raw` as the edit base instead.
@@ -143,7 +143,7 @@ Output budget (LUM-428): **default-mode** `doc show` caps the rendered body to t
143
143
 
144
144
  **`--section <heading>` (LUM-409)** prints just one heading-addressed section of the markdown source — a byte-faithful slice from the heading line through (not including) the next same-or-higher-level heading, subsections included. No header on stdout (the slice is a legal `doc patch` base); the current revision is printed to **stderr** as `Revision: N`. Mutually exclusive with `--raw`.
145
145
 
146
- - Section addressing: pass the heading text (`--section "D 状态表"`), case-insensitive fallback after an exact pass. Prefix with `#…` to pin the level when the same text exists at several depths (`--section "## Status"`).
146
+ - Section addressing: pass the heading text (`--section "D 状态表"`), matched in three tiers — exact, then case-insensitive, then (LUM-447) full-width↔half-width punctuation + whitespace normalization, so a half-width query (`问题(P4)`) lands on a full-width stored heading (`问题(P4)`) and vice-versa without copying the raw bytes. Prefix with `#…` to pin the level when the same text exists at several depths (`--section "## Status"`). Normalization never relaxes the ambiguity guard — if it matches more than one heading you still get the candidate list + exit 1.
147
147
  - Missing heading → exit 1 listing the available headings; ambiguous heading → exit 1 with a depth-disambiguation hint.
148
148
  - Requires a stored markdown source — same no-fallback rule and rebuild flow as `--raw`.
149
149
  - Heading detection is markdown-aware: `#` lines inside fenced code blocks or blockquotes are never section boundaries.
@@ -156,7 +156,7 @@ Use default mode when the user wants to read a doc; use `--raw` whenever the ful
156
156
  - Before a `doc patch` — read the section with `--section` first, note the `Revision:` line, edit, patch back with `--if-revision`.
157
157
  - User asks "what's the exact source of this doc", or an agent needs a faithful local copy of a live doc.
158
158
  - User asks "what's in section X" of a long doc — `--section` avoids pulling the whole body into context.
159
- - If `--raw`/`--section` errors (no stored source), walk the rebuild flow above instead of editing the rendered output.
159
+ - If `--raw`/`--section` errors (no stored source), run `lumo doc rebuild-source <doc>` to regenerate it losslessly, then retry — don't edit the rendered output.
160
160
 
161
161
  ### `lumo doc patch <doc> --section <heading>` — replace one section
162
162
 
@@ -201,7 +201,7 @@ Inserts the new content at the **end of the addressed section** (just before the
201
201
  | `--file <path>` | string | Content from file (project-local sandbox). |
202
202
  | `--if-revision <n>` | int | Only apply if the body is still at revision `n`; 409 + retry hint otherwise. |
203
203
 
204
- Same concurrency contract as `doc patch` (always a conditional commit; 409 on conflict). Requires a stored markdown source.
204
+ Same concurrency contract as `doc patch` (always a conditional commit; 409 on conflict). **End-of-document append (no `--section`) does NOT require a stored markdown source** (LUM-444): when the source is missing (web HTML edit / revision restore / legacy doc) it renders the new block to HTML and concatenates it onto the stored HTML body — so one web operation can no longer lock the whole doc against the agent write path. The source stays null (the doc is still HTML-only afterward; `--raw`/`--section`/`doc patch` keep erroring with the rebuild hint). **Section-addressed append (`--section`) still requires a stored source** — it needs the markdown to locate the heading boundary.
205
205
 
206
206
  ```bash
207
207
  lumo doc append cmd_xxx --section "F 待办队列" --content "- [ ] 评估 XYZ 论文"
@@ -234,6 +234,29 @@ lumo doc diff cmd_xxx --file docs/live-docs/research-intake-ledger.md
234
234
  - When a repo-tracked markdown source (e.g. `docs/live-docs/`) and the live doc may have drifted and the user asks which is current.
235
235
  - After a suspected concurrent edit: a clean diff (exit 0) proves remote and local are in sync.
236
236
 
237
+ ### `lumo doc rebuild-source <doc>` — regenerate the markdown source from the HTML body
238
+
239
+ The **recovery path for a source-less doc** (LUM-446). When a doc has no stored `sourceMarkdown` — a web HTML-direct edit or revision restore nulled it, or the doc predates source storage — every markdown write path (`--raw`, `--section`, `doc patch`, `doc append --section`, `doc diff`) is locked. This regenerates a valid source by serializing the **stored HTML structure model** back to markdown with a **lossless serializer that round-trips tables/rows/headings** (the default `doc show` render flattens tables — LUM-349 — so it was never a safe rebuild base). Only the `sourceMarkdown` column is backfilled; the rendered body is untouched, so the doc reads identically and you just regain the edit base.
240
+
241
+ | Flag | Type | Notes |
242
+ | ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
243
+ | `--allow-shrink` | boolean | Commit even if the rebuilt source re-renders with fewer tables/rows/headings than the stored body (default: 422 reject). |
244
+ | `--force` | boolean | Re-derive even when a source already exists (default: 409 — protects a byte-faithful human source from a downgrade). |
245
+ | `--if-revision <n>` | int | Only apply if the body is still at this revision (from `doc show`). |
246
+
247
+ The rebuild is **structure-guarded** (the same LUM-410 口径 as `doc update`/`doc patch`): if the serializer would drop any table/row/heading, it is rejected with **422** rather than silently committing a flattened source — `--allow-shrink` is the explicit escape hatch. A doc that already has a source is refused **409** unless `--force`.
248
+
249
+ ```bash
250
+ lumo doc rebuild-source cmd_xxx # restore a source-less doc; --raw works after
251
+ lumo doc rebuild-source cmd_xxx --force # re-derive even if a source already exists
252
+ ```
253
+
254
+ ### When to suggest `doc rebuild-source`
255
+
256
+ - A `--raw` / `--section` / `doc patch` / `doc diff` call errored with "no stored markdown source" — rebuild, then retry. This is the first thing to try, not a manual reconstruction.
257
+ - A table-heavy live doc (e.g. a `docs/live-docs/` registry) lost its source after a web operation and the markdown write path is locked.
258
+ - Do **not** run it on a doc that already has a good source unless the user explicitly wants to re-derive it (then pass `--force`) — it replaces the byte-faithful source with a serializer-derived one.
259
+
237
260
  ### `lumo doc list [flags]` — list documents
238
261
 
239
262
  Default behavior: lists every document the current user can see, as fixed-width rows: `<cuid> <SCOPE> <project|-> <title>`.
@@ -44,6 +44,7 @@ Attribution requires the CC session id to reach the server: `lumo task update <i
44
44
  When the bound task has live blockers or SUGGESTED dependency candidates, the attach output and session-start hook context inject a dependency warning block. The warning is built by `buildBlockerWarningSectionForTask` and is **omitted entirely** (empty string) when nothing is actionable — no output appears when there are no live blockers and no SUGGESTED candidates.
45
45
 
46
46
  **Trigger conditions (OR — either or both may appear):**
47
+
47
48
  1. At least one CONFIRMED "blocked by" edge where the blocking task's status is not DONE.
48
49
  2. At least one SUGGESTED (unconfirmed) dependency candidate on the task.
49
50
 
@@ -74,6 +75,7 @@ Detected 3 candidate dependencies awaiting confirmation: run `lumo task deps lis
74
75
  - The function never throws — if it fails it silently returns an empty string, leaving the session start unaffected.
75
76
 
76
77
  **Agent guidance — watch for EITHER the `## ⚠ Dependency alerts` header (form A) OR the standalone hint line (form B):**
78
+
77
79
  - **Form A — live blockers:** Evaluate whether to wait. If the blocking task's work overlaps with yours (same files, same API surface), starting immediately risks rework. Read the blocker's status and open PR note before deciding.
78
80
  - **Stale or wrong edge?** Run `lumo task deps list <LUM-N>` to inspect the full edge list, then `lumo task deps rm <LUM-N> <edge> --yes` (if manually added and now obsolete) or `lumo task deps dismiss <LUM-N> <edge>` (if a false positive from detection).
79
81
  - **Candidate hint present (form A or B)?** Run `lumo task deps list <LUM-N>` and review each SUGGESTED edge — confirm real ones, dismiss false positives. Leaving SUGGESTED edges unreviewed means repeated hints every session.
@@ -97,20 +99,17 @@ What it does:
97
99
 
98
100
  **After attaching, always run `lumo task context <identifier>` to load the task background.**
99
101
 
100
- #### Overwriting an existing binding (LUM-266)
102
+ #### Lifetime lock (LUM-459)
101
103
 
102
- Re-attaching a session that's already bound to a **different** task no longer silently clobbers the binding. The server returns the current binding instead of overwriting, and the CLI decides what to do:
104
+ `Session.taskId` is **write-once**. Re-attaching to the **same** task is always a no-op re-bind (idempotent, re-emits context). Attaching to a **different** task is refused with HTTP 409 the server returns `{ error, currentTaskIdentifier, currentTaskTitle }` and the CLI prints:
103
105
 
104
- - **On a TTY** (a human running it directly): prompts `Already bound to LUM-X. Rebind to LUM-Y? [y/N]`. Answering `y`/`yes` re-binds to `LUM-Y`; anything else (including bare Enter) cancels and leaves `LUM-X` bound.
105
- - **Off a TTY** (the usual agent case — `stdin` is not interactive): the command **refuses** and prints `Session already bound to LUM-X … Re-run with --force …` without overwriting. Exit code is `0` (a safe refusal, not an error).
106
- - **`--force`**: skips the prompt/refusal and overwrites unconditionally. Re-attaching to the **same** task is always a no-op re-bind (never prompts).
107
-
108
- ```bash
109
- lumo session attach LUM-42 # already on LUM-7 → prompts (TTY) / refuses (agent)
110
- lumo session attach LUM-42 --force # overwrite without confirmation
111
106
  ```
107
+ Error: this session is permanently bound to LUM-7 "Other task". A session works one task for its lifetime — start a new Claude Code session to work on LUM-42.
108
+ ```
109
+
110
+ There is no `--force` and no `session detach`. To work on a different task, open a new terminal / Claude Code session and run `lumo session attach <new-task>` there.
112
111
 
113
- **Agent guidance:** when `session attach` reports the session is already bound to a different task, **ask the user** whether to switch before re-running with `--force`. Don't auto-`--force` the existing binding may be intentional (e.g. a manual attach the user ran earlier). Alternatively run `lumo session detach` first, then a clean `session attach`.
112
+ **Agent guidance:** if `session attach` returns 409, do not retry or look for a workaroundstart a fresh Claude Code session for the target task.
114
113
 
115
114
  ### Parallel sessions
116
115
 
@@ -126,16 +125,6 @@ lumo session status
126
125
 
127
126
  When to suggest: the user asks "which task am I on", "what's this session bound to", or you need to decide whether to suggest `session attach` for a mentioned task ID.
128
127
 
129
- ### `lumo session detach` — clear the current binding
130
-
131
- Idempotent — running it twice is fine, the second call reports `already unbound`. Past hook events keep their original `taskId`; only future events on this session will be untagged.
132
-
133
- ```bash
134
- lumo session detach
135
- ```
136
-
137
- When to suggest: the user wants to stop tagging the current session with the active task (e.g., switching to unrelated exploratory work without binding to a different task).
138
-
139
128
  ### `lumo session wrap [--yes] [--dry-run] [--used <indices>]` — wrap-up panel: progress comment + memory review + fragment-usage vote + blocked-tag prompt
140
129
 
141
130
  Session-end wrap-up panel with **four sections, run in order**:
@@ -188,6 +177,21 @@ shared board requires an interactive `y`, so `--yes` (and non-TTY) prints the
188
177
  suggestion and moves on rather than silently flipping board state. When there's
189
178
  nothing to prompt, the section prints "(no content)".
190
179
 
180
+ **After the panel — open-crossings reminder (LUM-448).** Once the four sections
181
+ finish, `session wrap` prints a one-shot read-only reminder **if** the bound
182
+ task has ≥1 OPEN (undispositioned) boundary crossing: `⚠ N open boundary
183
+ crossing(s) on LUM-N still undispositioned:` then a line per crossing `• [SEVERITY]
184
+ CATEGORY` and a web pointer. When the read genuinely comes back empty — or the
185
+ session is unbound — it prints **nothing** (truly silent, not a "(no content)"
186
+ line), so a clean task adds no wrap-up noise. **But a crossings-check failure is
187
+ not silent (LUM-480):** if the read errors (network / server), it prints
188
+ `⚠ Could not check boundary crossings on LUM-N (network/server error) — unable
189
+ to confirm whether any are still undispositioned`, so a failed safety check never
190
+ masquerades as "0 open / safe". **Awareness only:** it points at the web
191
+ acceptance panel; there is **no CLI path** to disposition or clear a crossing.
192
+ Disposition stays web + human-only (LUM-426/435/422) — an agent/CLI bearer cannot
193
+ clear its own crossing from the terminal.
194
+
191
195
  ```bash
192
196
  lumo session wrap # interactive: preview each section, choose per-section
193
197
  lumo session wrap --yes # progress posted + memories kept; blocked tag NOT auto-applied (needs interactive y)
@@ -218,8 +222,3 @@ see the numbered fragment list, decide which you actually used, then re-run with
218
222
  When to suggest: at the end of a working session on a bound task, to record what
219
223
  was done as a progress comment — offer `lumo session wrap` rather than composing
220
224
  a `task comment` by hand.
221
-
222
- ### When to suggest session binding
223
-
224
- - If the user mentions a task ID (e.g., "let's work on LUM-42") and no session is currently bound, **suggest running `lumo session attach`**.
225
- - If the user switches tasks mid-session, run `attach` with the new task ID — the server overwrites the existing binding atomically.
@@ -125,10 +125,30 @@ what's unmet and why (the exact failure tails), and how many rounds are left.
125
125
 
126
126
  - Header: task identifier/title/status + `verification round N/3` (round 0 =
127
127
  never verified) + an escalation warning when the machine loop is exhausted.
128
+ - **Machine verification rollup** (LUM-470) — directly under the `Criteria`
129
+ header, one line `Machine verification: N machine-verified / M human override
130
+ (of T MACHINE criteria)` over the active MACHINE criteria, aligned with the web
131
+ read model (LUM-456). Printed whenever the contract has ≥1 MACHINE criterion,
132
+ so the terminal rollup never reads as all-human when a checkpointer actually
133
+ verified the work.
128
134
  - **Criteria** — every criterion as `<glyph> <id> [TYPE] SOURCE@rN
129
135
  statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with its
130
136
  checkpointer and latest verdict line (evidence pointer on pass, failure
131
137
  tail on fail). `REVIEW_ADDED@rN` provenance is visible per row.
138
+ - A passing **MACHINE** criterion's verdict line carries a machine-state tag
139
+ derived from the read model's `machinePassed` flag, NOT the latest verdict
140
+ (LUM-470): `· machine-verified` when a checkpointer actually passed it (even
141
+ after a human later signs the task off), or `· human override (no machine
142
+ pass)` when it passes only on a human sign-off with no machine run underneath.
143
+ This keeps the terminal honest with web — a machine-verified criterion that
144
+ a human co-signed no longer reads as a plain human pass.
145
+ - A pass can carry a **`⚠ pre-edit version`** note (LUM-457): the criterion
146
+ was changed after that verdict (reworded, or its checkpointer was swapped so
147
+ the recorded evidence ran a different command). The pass still counts as met
148
+ (a stale pass does not block DONE — render-only signal), but it vouches for
149
+ an older version — **re-run `lumo verify` to re-confirm against the current
150
+ criterion.** This is the habit whenever you edit a MACHINE criterion's
151
+ checkpointer mid-task: change the check, then re-verify so the green is honest.
132
152
  - **History** — one line per recorded round: `rN · timestamp · X PASS / Y FAIL`.
133
153
  - **Last round failures** — the most recent round's FAIL verdicts with their
134
154
  rejection reasons (why the last round bounced).
@@ -136,12 +156,42 @@ statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with it
136
156
  failed or never verified, HUMAN ones included). This list IS the plan —
137
157
  it is recomputed from the event log on every read, never maintained
138
158
  separately. Empty + rounds recorded = awaiting human adjudication.
159
+ - **Open boundary crossings** (LUM-448) — a trailing safety block when the
160
+ task has ≥1 OPEN (undispositioned) forbidden-action crossing: a count, then
161
+ one line per crossing `• [SEVERITY] CATEGORY — <clipped detail>`
162
+ (highest-severity first), each followed by a read-only **attribution** line
163
+ `↳ by model=<m> · agent=<type>[/branch] · session=<8-char prefix>` (LUM-469 —
164
+ who/what crossed; any dimension that couldn't be resolved server-side prints
165
+ `unknown`, never a fabricated value), then a pointer to the web acceptance
166
+ panel. Silent when there are none, so it never overshadows the criteria.
167
+ **Read-only awareness** — this surfaces crossings detected elsewhere
168
+ (LUM-426/435/442); there is no CLI path to disposition or clear one.
169
+ Disposition stays web + human-only (LUM-426/435/422): an agent/CLI bearer
170
+ cannot clear its own crossing from the terminal. **The check fails closed
171
+ (LUM-480):** if the crossings read itself errors (network / server / parse),
172
+ the block prints `⚠ Boundary-crossing check failed (network/server error) —
173
+ could not confirm whether any are undispositioned` instead of staying silent.
174
+ Silence means a successful read with zero open crossings, never a failed
175
+ check — a hiccup can no longer masquerade as "all clear".
139
176
 
140
177
  ### --json contract
141
178
 
142
179
  `--json` emits the full read model with a top-level `version` field
143
180
  (currently `1`). The schema is versioned: breaking shape changes bump the
144
181
  major; additive fields don't. Pin on `version` when scripting against it.
182
+ Each criterion carries `machinePassed` (boolean — a checkpointer currently
183
+ vouches for it; LUM-456/470), and the payload carries a top-level
184
+ `machineVerification` aggregate `{ total, machineVerified, humanOverridden }`
185
+ over the active MACHINE criteria — read these, not `latestVerdict` alone, to
186
+ tell a machine-verified criterion from a human override.
187
+ The open boundary crossings ride along as an additive top-level
188
+ `openCrossings` (each entry `{ id, category, severity, detail, attribution }`,
189
+ where `attribution` is `{ workspaceMemberId, sessionId, agent, worktreeBranch,
190
+ model }` with every field nullable — null = unknown, never fabricated; LUM-469;
191
+ the array length is the count) — same read-only awareness, no write path.
192
+ **`openCrossings` is `null` when the crossings check failed (LUM-480)** —
193
+ distinct from `[]`, which is a successful read with zero open crossings. Script
194
+ consumers must treat `null` as "unknown / could not confirm", not "safe".
145
195
 
146
196
  `status` reads; `verify` judges. Running status never starts a round, never
147
197
  escalates, and never changes task state — loop rules (cap 3, IN_REVIEW on
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.docRebuildSource = docRebuildSource;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const resolve_doc_id_1 = require("../lib/resolve-doc-id");
7
+ const sanitize_1 = require("../lib/sanitize");
8
+ /**
9
+ * `lumo doc rebuild-source <doc>` (LUM-446).
10
+ *
11
+ * Recovery path for a source-less doc: regenerates `Document.sourceMarkdown`
12
+ * from the stored HTML body using the lossless serializer (tables round-trip),
13
+ * re-enabling `doc show --raw` / `diff` / `patch` / `append`. The rebuilt source
14
+ * is structure-guarded server-side: any table/tr/heading shrink is rejected 422
15
+ * (zero silent flattening — LUM-410 口径) unless --allow-shrink is passed. A doc
16
+ * that already has a source is refused 409 unless --force re-derives it.
17
+ */
18
+ async function docRebuildSource(reference, opts) {
19
+ if (!reference) {
20
+ console.error('Error: missing <doc>. Usage: lumo doc rebuild-source <doc> [--allow-shrink] [--force] [--if-revision <n>]');
21
+ return 1;
22
+ }
23
+ let ifRevision;
24
+ if (opts.ifRevision !== undefined) {
25
+ if (!/^\d+$/.test(opts.ifRevision)) {
26
+ console.error(`Error: --if-revision must be a non-negative integer (got "${opts.ifRevision}")`);
27
+ return 1;
28
+ }
29
+ ifRevision = Number(opts.ifRevision);
30
+ }
31
+ const creds = (0, config_1.readCredentials)();
32
+ if (!creds) {
33
+ console.error('Error: not logged in. Run `lumo auth login` first.');
34
+ return 1;
35
+ }
36
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
37
+ const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
38
+ if (!id) {
39
+ console.error(`Error: Document not found: ${reference}`);
40
+ return 1;
41
+ }
42
+ const body = {};
43
+ if (opts.allowShrink)
44
+ body.allowShrink = true;
45
+ if (opts.force)
46
+ body.force = true;
47
+ if (ifRevision !== undefined)
48
+ body.ifRevision = ifRevision;
49
+ const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}/rebuild-source`, {
50
+ method: 'POST',
51
+ headers: {
52
+ Authorization: `Bearer ${creds.token}`,
53
+ 'Content-Type': 'application/json',
54
+ },
55
+ body: JSON.stringify(body),
56
+ });
57
+ if (!res.ok) {
58
+ const text = await res.text();
59
+ const message = (0, api_1.extractErrorMessage)(text);
60
+ if (res.status === 409) {
61
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
62
+ console.error('Hint: the doc already has a markdown source. Inspect it with ' +
63
+ `\`lumo doc show ${reference} --raw\`; pass --force only if you intend ` +
64
+ 'to replace it with a freshly serialized one.');
65
+ return 1;
66
+ }
67
+ if (res.status === 422) {
68
+ // Structure guard rejection (LUM-410): the serializer would drop
69
+ // structure the stored body has — refuse rather than commit a flattened
70
+ // source.
71
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
72
+ console.error('Hint: this means the rebuild could not reproduce the stored structure ' +
73
+ 'losslessly. Re-run with --allow-shrink only if the loss is acceptable.');
74
+ return 1;
75
+ }
76
+ console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(message)}`);
77
+ return 1;
78
+ }
79
+ const { document } = (await res.json());
80
+ const escaped = (0, sanitize_1.sanitizeField)(document.title).replace(/"/g, '\\"');
81
+ const bytes = typeof document.sourceBytes === 'number'
82
+ ? ` (${document.sourceBytes} bytes)`
83
+ : '';
84
+ console.log(`Rebuilt markdown source for ${document.id} "${escaped}"${bytes}. ` +
85
+ `\`lumo doc show ${document.id} --raw\` now works.`);
86
+ }
@@ -4,26 +4,20 @@ exports.sessionAttach = sessionAttach;
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 line_prompt_1 = require("../lib/line-prompt");
8
7
  /**
9
8
  * `lumo session attach <identifier>` — bind the currently-running
10
9
  * Claude Code session to a task.
11
10
  *
12
11
  * Required environment: `CLAUDE_CODE_SESSION_ID` (set automatically by
13
- * Claude Code). Must be invoked from inside a Claude Code session — there
14
- * is no longer a global "current task" pointer for terminals outside CC.
12
+ * Claude Code). Must be invoked from inside a Claude Code session.
15
13
  *
16
- * The binding lives entirely on the server (`Session.taskId`); subsequent
17
- * hooks read it back via the session row. The CLI keeps no local sentinel.
18
- *
19
- * Re-binding a session that's already attached to a *different* task no
20
- * longer silently clobbers `Session.taskId` (LUM-266): the server returns
21
- * the current binding and we confirm before overwriting —
22
- * - `--force` skips the prompt and overwrites directly;
23
- * - on a TTY we ask `Already bound to LUM-X. Rebind to LUM-Y? [y/N]`;
24
- * - off a TTY (the usual agent case) we refuse and point at `--force`.
14
+ * The binding is a **lifetime lock** (LUM-459): `Session.taskId` is write-once.
15
+ * Re-attaching to the *same* task is an idempotent no-op (re-emits context).
16
+ * Attaching to a *different* task is refused with HTTP 409 — there is no
17
+ * `--force` and no `session detach`; a different task requires a brand-new
18
+ * Claude Code session.
25
19
  */
26
- async function sessionAttach(identifier, options = {}) {
20
+ async function sessionAttach(identifier) {
27
21
  if (!identifier) {
28
22
  console.error('Error: missing <identifier>. Usage: lumo session attach <LUM-42>');
29
23
  return 1;
@@ -41,75 +35,63 @@ async function sessionAttach(identifier, options = {}) {
41
35
  }
42
36
  const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
43
37
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
44
- const bind = async (force) => {
45
- let res;
38
+ let res;
39
+ try {
40
+ res = await fetch(url, {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ Authorization: `Bearer ${creds.token}`,
45
+ },
46
+ body: JSON.stringify({ taskIdentifier: identifier }),
47
+ });
48
+ }
49
+ catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
52
+ return 1;
53
+ }
54
+ if (res.status === 401) {
55
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
56
+ return 1;
57
+ }
58
+ if (res.status === 404) {
59
+ let message = 'Not found';
46
60
  try {
47
- res = await fetch(url, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json',
51
- Authorization: `Bearer ${creds.token}`,
52
- },
53
- body: JSON.stringify({ taskIdentifier: identifier, force }),
54
- });
55
- }
56
- catch (err) {
57
- const msg = err instanceof Error ? err.message : String(err);
58
- console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
59
- return { ok: false, code: 1 };
61
+ const data = (await res.json());
62
+ if (data.error)
63
+ message = data.error;
60
64
  }
61
- if (res.status === 401) {
62
- console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
63
- return { ok: false, code: 1 };
65
+ catch {
66
+ // fall through
64
67
  }
65
- if (res.status === 404) {
66
- let message = 'Not found';
67
- try {
68
- const data = (await res.json());
69
- if (data.error)
70
- message = data.error;
71
- }
72
- catch {
73
- // fall through
68
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
69
+ return 1;
70
+ }
71
+ // Lifetime lock (LUM-459): the session is permanently bound to another task.
72
+ if (res.status === 409) {
73
+ let current = 'another task';
74
+ try {
75
+ const data = (await res.json());
76
+ if (data.currentTaskIdentifier) {
77
+ current = data.currentTaskTitle
78
+ ? `${data.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(data.currentTaskTitle)}"`
79
+ : data.currentTaskIdentifier;
74
80
  }
75
- console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
76
- return { ok: false, code: 1 };
77
81
  }
78
- if (!res.ok) {
79
- console.error(`Error: bind-task failed (HTTP ${res.status})`);
80
- return { ok: false, code: 1 };
82
+ catch {
83
+ // fall through with the generic phrasing
81
84
  }
82
- return { ok: true, body: (await res.json()) };
83
- };
84
- // First attempt. `--force` overwrites unconditionally; otherwise the server
85
- // may answer `already-bound` so we can confirm before clobbering.
86
- const first = await bind(options.force === true);
87
- if (!first.ok)
88
- return first.code;
89
- let body = first.body;
90
- if (body.status === 'already-bound') {
91
- // Reached only when not forced (force=true would have overwritten).
92
- if (!process.stdin.isTTY) {
93
- console.error(`Session already bound to ${body.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(body.currentTaskTitle)}". ` +
94
- `Not overwriting. Re-run with --force to switch to ${identifier} ` +
95
- '(or run `lumo session detach` first).');
96
- return 0;
97
- }
98
- const answer = await (0, line_prompt_1.promptLine)(`Already bound to ${body.currentTaskIdentifier}. Rebind to ${identifier}? [y/N] `);
99
- if (!/^y(es)?$/i.test(answer)) {
100
- console.log(`Cancelled — still bound to ${body.currentTaskIdentifier}.`);
101
- return 0;
102
- }
103
- const second = await bind(true);
104
- if (!second.ok)
105
- return second.code;
106
- body = second.body;
85
+ console.error(`Error: this session is permanently bound to ${current}. ` +
86
+ 'A session works one task for its lifetime — start a new Claude Code ' +
87
+ `session to work on ${identifier}.`);
88
+ return 1;
107
89
  }
108
- if (body.status === 'already-bound') {
109
- // Defensive: a forced bind should never report already-bound.
110
- console.error(`Error: bind did not take effect (still bound to ${body.currentTaskIdentifier}).`);
90
+ if (!res.ok) {
91
+ console.error(`Error: bind-task failed (HTTP ${res.status})`);
111
92
  return 1;
112
93
  }
94
+ const body = (await res.json());
113
95
  console.log(`Attached session ${sessionId} to ${body.taskIdentifier} "${(0, sanitize_1.sanitizeField)(body.taskTitle)}"`);
114
96
  console.log(`Re-tagged ${body.retaggedEventCount} previously-untagged event${body.retaggedEventCount === 1 ? '' : 's'} in this session.`);
115
97
  // Warnings come before contract/memory: matches the hook injection order —
@@ -7,6 +7,7 @@ const progress_comment_section_1 = require("./wrap/progress-comment-section");
7
7
  const memory_review_section_1 = require("./wrap/memory-review-section");
8
8
  const fragment_usage_section_1 = require("./wrap/fragment-usage-section");
9
9
  const blocked_prompt_section_1 = require("./wrap/blocked-prompt-section");
10
+ const crossings_reminder_1 = require("./wrap/crossings-reminder");
10
11
  /**
11
12
  * `lumo session wrap [--yes] [--dry-run]`
12
13
  *
@@ -40,4 +41,11 @@ async function sessionWrap(options) {
40
41
  yes: options.yes === true,
41
42
  dryRun: options.dryRun === true,
42
43
  });
44
+ // After the panel: a read-only nudge if the bound task has open boundary
45
+ // crossings still undispositioned (LUM-448). Silent when there are none —
46
+ // a clean task adds no wrap-up noise. Awareness only; clearing a crossing is
47
+ // web + human-only (LUM-426/435/422).
48
+ const reminder = await (0, crossings_reminder_1.openCrossingReminder)(creds);
49
+ if (reminder)
50
+ process.stdout.write(reminder);
43
51
  }
@@ -20,6 +20,12 @@ function formatCriteriaRows(criteria) {
20
20
  if (c.checkpointer) {
21
21
  lines.push(` ↳ check: ${(0, sanitize_1.sanitizeField)(c.checkpointer)}`);
22
22
  }
23
+ if (c.judgeSteps) {
24
+ const judgeLines = (0, sanitize_1.sanitizeField)(c.judgeSteps).split('\n');
25
+ lines.push(` ↳ judge: ${judgeLines[0]}`);
26
+ for (const jl of judgeLines.slice(1))
27
+ lines.push(` ${jl}`);
28
+ }
23
29
  }
24
30
  return lines.length > 0 ? lines.join('\n') + '\n' : '';
25
31
  }
@@ -104,6 +104,31 @@ async function taskCriteriaSet(identifier, options) {
104
104
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
105
105
  if (sessionId)
106
106
  headers['X-Lumo-Session-Id'] = sessionId;
107
+ // LUM-437: when a contract already exists, preserve criterion identity across
108
+ // an after-start edit by matching submitted items to existing criteria by exact
109
+ // statement and attaching their ids. Best-effort — fall back to id-less submit.
110
+ let criteriaItems = parsed.items;
111
+ if (!options.human) {
112
+ try {
113
+ const listRes = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria`, { headers: { Authorization: `Bearer ${creds.token}` } });
114
+ if (listRes.ok) {
115
+ const listData = (await listRes.json());
116
+ const activeByStatement = new Map(listData.criteria.map(c => [c.statement, c.id]));
117
+ criteriaItems = parsed.items.map(item => {
118
+ if (item !== null && typeof item === 'object' && !('id' in item)) {
119
+ const stmt = item['statement'];
120
+ if (typeof stmt === 'string' && activeByStatement.has(stmt)) {
121
+ return { ...item, id: activeByStatement.get(stmt) };
122
+ }
123
+ }
124
+ return item;
125
+ });
126
+ }
127
+ }
128
+ catch {
129
+ // leave criteriaItems as-is; the server still records via full diff
130
+ }
131
+ }
107
132
  let res;
108
133
  try {
109
134
  res = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/criteria`, {
@@ -111,7 +136,7 @@ async function taskCriteriaSet(identifier, options) {
111
136
  headers,
112
137
  body: JSON.stringify({
113
138
  source: options.human ? 'HUMAN_EDIT' : 'AGENT_DRAFT',
114
- criteria: parsed.items,
139
+ criteria: criteriaItems,
115
140
  ...(causeTag ? { causeTag } : {}),
116
141
  }),
117
142
  });
@@ -143,4 +168,7 @@ async function taskCriteriaSet(identifier, options) {
143
168
  if (data.warning) {
144
169
  process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.warning)}\n`);
145
170
  }
171
+ if (data.judgeStepsWarning) {
172
+ process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.judgeStepsWarning)}\n`);
173
+ }
146
174
  }
@@ -6,6 +6,17 @@ const config_1 = require("../lib/config");
6
6
  const api_1 = require("../lib/api");
7
7
  const resolve_bound_task_1 = require("../lib/resolve-bound-task");
8
8
  const sanitize_1 = require("../lib/sanitize");
9
+ const open_crossings_1 = require("../lib/open-crossings");
10
+ /** One-line a possibly-multiline crossing detail and cap it so the safety block
11
+ * stays a glance, never a wall (it must not overshadow the criteria). */
12
+ const CROSSING_DETAIL_CAP = 160;
13
+ function oneLineDetail(detail) {
14
+ const firstLine = detail.split('\n')[0] ?? '';
15
+ const clean = (0, sanitize_1.sanitizeField)(firstLine).trim();
16
+ return clean.length > CROSSING_DETAIL_CAP
17
+ ? `${clean.slice(0, CROSSING_DETAIL_CAP)}…`
18
+ : clean;
19
+ }
9
20
  const REASON_TAIL = 400;
10
21
  function tail(s, max) {
11
22
  return s.length > max ? `…${s.slice(-max)}` : s;
@@ -16,7 +27,7 @@ function tail(s, max) {
16
27
  * glyph in front — ✓ pass, ✗ fail, ○ no verdict yet — so REVIEW_ADDED
17
28
  * provenance stays explicitly visible in every row.
18
29
  */
19
- function formatTaskStatus(data) {
30
+ function formatTaskStatus(data, extras = {}) {
20
31
  const lines = [];
21
32
  const t = data.task;
22
33
  lines.push(`${t.identifier} ${(0, sanitize_1.sanitizeField)(t.title)}`);
@@ -30,10 +41,18 @@ function formatTaskStatus(data) {
30
41
  if (data.criteria.length === 0) {
31
42
  lines.push('');
32
43
  lines.push(`No acceptance criteria on ${t.identifier} — draft 3–7 and submit with lumo task criteria set ${t.identifier} --file <criteria.json>`);
44
+ pushOpenCrossings(lines, extras);
33
45
  return lines.join('\n') + '\n';
34
46
  }
35
47
  lines.push('');
36
48
  lines.push(`Criteria (${data.criteria.length} total, ${data.nextActions.length} unmet):`);
49
+ // LUM-470: honest machine-verification rollup over the active MACHINE criteria
50
+ // (same read model as web, LUM-456) — so the terminal rollup never reads as
51
+ // all-human when a checkpointer actually verified the work.
52
+ const mv = data.machineVerification;
53
+ if (mv.total > 0) {
54
+ lines.push(`Machine verification: ${mv.machineVerified} machine-verified / ${mv.humanOverridden} human override (of ${mv.total} MACHINE criteria)`);
55
+ }
37
56
  for (const c of data.criteria) {
38
57
  const glyph = c.latestVerdict == null
39
58
  ? '○'
@@ -46,6 +65,15 @@ function formatTaskStatus(data) {
46
65
  if (c.checkpointer) {
47
66
  lines.push(` ↳ check: ${(0, sanitize_1.sanitizeField)(c.checkpointer)}`);
48
67
  }
68
+ // LUM-465: agent-drafted human-judging guidance, rendered as an indented
69
+ // block under a HUMAN criterion (multi-line steps stay aligned).
70
+ if (c.judgeSteps) {
71
+ const judgeLines = (0, sanitize_1.sanitizeField)(c.judgeSteps).split('\n');
72
+ lines.push(` ↳ judge: ${judgeLines[0]}`);
73
+ for (const jl of judgeLines.slice(1)) {
74
+ lines.push(` ${jl}`);
75
+ }
76
+ }
49
77
  const v = c.latestVerdict;
50
78
  if (v == null) {
51
79
  lines.push(' (no verdict yet)');
@@ -60,7 +88,21 @@ function formatTaskStatus(data) {
60
88
  const evidencePart = v.evidencePointer
61
89
  ? ` · ${(0, sanitize_1.sanitizeField)(v.evidencePointer)}`
62
90
  : '';
63
- lines.push(` ✓ ${v.verdict}@r${v.round}${evidencePart}`);
91
+ // LUM-470: tag a passing MACHINE criterion by the read model's
92
+ // machinePassed flag, not the latest verdict — a criterion a checkpointer
93
+ // verified reads as machine-verified even after a human signs off, and a
94
+ // pass with no machine run underneath reads as a human override.
95
+ const machineTag = c.verifierType === 'MACHINE'
96
+ ? c.machinePassed
97
+ ? ' · machine-verified'
98
+ : ' · human override (no machine pass)'
99
+ : '';
100
+ lines.push(` ✓ ${v.verdict}@r${v.round}${evidencePart}${machineTag}`);
101
+ // LUM-457: a pass that vouches for a pre-edit version of the criterion —
102
+ // render-only downgrade, the criterion still counts met.
103
+ if (c.verdictStale || c.checkMismatch) {
104
+ lines.push(' ⚠ pre-edit version — criterion changed since this check; re-run `lumo verify` to re-confirm');
105
+ }
64
106
  }
65
107
  }
66
108
  if (data.verificationHistory.length > 0) {
@@ -108,8 +150,60 @@ function formatTaskStatus(data) {
108
150
  : 'Remaining criteria are HUMAN-only — finish the work and hand off for human review.');
109
151
  }
110
152
  }
153
+ pushOpenCrossings(lines, extras);
111
154
  return lines.join('\n') + '\n';
112
155
  }
156
+ /**
157
+ * Append the OPEN boundary-crossings safety block (LUM-448) — a count, one line
158
+ * per crossing with its severity + category + clipped detail, and a pointer to
159
+ * the human-only web disposition panel. Trailing (after the criteria) and
160
+ * silent when there are none, so it never overshadows the acceptance contract.
161
+ * Read-only awareness: the line points at the web UI; it offers no way to clear
162
+ * a crossing from the terminal (LUM-426/435/422).
163
+ */
164
+ function pushOpenCrossings(lines, extras) {
165
+ const result = extras.openCrossings;
166
+ if (!result)
167
+ return;
168
+ // LUM-480: a failed check is NOT "0 open / safe" — say so explicitly rather
169
+ // than rendering an empty (implicitly-clear) block.
170
+ if (result.status === 'error') {
171
+ lines.push('');
172
+ lines.push('⚠ Boundary-crossing check failed (network/server error) — could not confirm whether any are undispositioned.');
173
+ return;
174
+ }
175
+ const open = result.crossings;
176
+ if (open.length === 0)
177
+ return;
178
+ lines.push('');
179
+ lines.push(`⚠ Open boundary crossings (${open.length} undispositioned):`);
180
+ for (const c of open) {
181
+ const detail = oneLineDetail(c.detail);
182
+ const tail = detail ? ` — ${detail}` : '';
183
+ lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
184
+ lines.push(` ${formatAttribution(c.attribution)}`);
185
+ }
186
+ lines.push(' Disposition is human-only in the web acceptance panel:');
187
+ if (extras.dispositionUrl) {
188
+ lines.push(` ${extras.dispositionUrl}`);
189
+ }
190
+ }
191
+ /**
192
+ * Compact attribution line for an open crossing (LUM-469): which model + which
193
+ * agent/session committed it, so a reviewer can audit who/what crossed from the
194
+ * terminal. Every dimension that the server couldn't resolve renders `unknown`
195
+ * — never a fabricated value. The session UUID is clipped to a correlatable
196
+ * prefix; the worktree branch (which branch) is appended to the agent when known.
197
+ */
198
+ function formatAttribution(a) {
199
+ const agent = a.agent
200
+ ? a.worktreeBranch
201
+ ? `${a.agent}/${a.worktreeBranch}`
202
+ : a.agent
203
+ : 'unknown';
204
+ const session = a.sessionId ? a.sessionId.slice(0, 8) : 'unknown';
205
+ return `↳ by model=${a.model ?? 'unknown'} · agent=${agent} · session=${session}`;
206
+ }
113
207
  /**
114
208
  * `lumo task status [task] [--json]` — the agent's read-only self-check
115
209
  * (LUM-344): contract + latest verdicts + verification history + next
@@ -126,8 +220,7 @@ async function taskStatus(identifier, options = {}) {
126
220
  const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
127
221
  let taskId = identifier;
128
222
  if (!taskId) {
129
- taskId =
130
- (await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(base, creds.token)) ?? undefined;
223
+ taskId = (await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(base, creds.token)) ?? undefined;
131
224
  if (!taskId) {
132
225
  console.error('Error: no task given and this session is not bound to one.\n' +
133
226
  'Run `lumo task status <LUM-N>`, or bind first with `lumo session attach <LUM-N>`.');
@@ -156,11 +249,25 @@ async function taskStatus(identifier, options = {}) {
156
249
  return 1;
157
250
  }
158
251
  const data = (await res.json());
252
+ // Read-only awareness (LUM-448): surface the task's OPEN boundary crossings
253
+ // via the existing LUM-435 endpoint. fetchOpenCrossings returns a result that
254
+ // distinguishes a check FAILURE from a genuine 0-open read (LUM-480), so this
255
+ // supplementary safety signal never masquerades a hiccup as "all clear" — yet
256
+ // it still never throws, so it can't block the primary acceptance status. The
257
+ // resolved taskId (the identifier the status was fetched for) is the key here.
258
+ const crossingsResult = await (0, open_crossings_1.fetchOpenCrossings)(base, creds.token, taskId);
159
259
  if (options.json) {
160
260
  // JSON.stringify escapes control chars (…), so the payload is safe
161
261
  // to emit raw — and consumers get byte-faithful server data.
162
- process.stdout.write(JSON.stringify(data, null, 2) + '\n');
262
+ // openCrossings rides alongside as an additive field: an array on success
263
+ // (count = length), or `null` when the check failed (LUM-480) — distinct
264
+ // from `[]`, which is a successful read with zero open crossings.
265
+ const openCrossings = crossingsResult.status === 'ok' ? crossingsResult.crossings : null;
266
+ process.stdout.write(JSON.stringify({ ...data, openCrossings }, null, 2) + '\n');
163
267
  return;
164
268
  }
165
- process.stdout.write(formatTaskStatus(data));
269
+ process.stdout.write(formatTaskStatus(data, {
270
+ openCrossings: crossingsResult,
271
+ dispositionUrl: (0, open_crossings_1.dispositionUrl)(base, creds.workspaceSlug ?? 'lumo', data.task.identifier),
272
+ }));
166
273
  }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCrossingReminder = formatCrossingReminder;
4
+ exports.openCrossingReminder = openCrossingReminder;
5
+ const resolve_bound_task_1 = require("../../lib/resolve-bound-task");
6
+ const sanitize_1 = require("../../lib/sanitize");
7
+ const open_crossings_1 = require("../../lib/open-crossings");
8
+ /**
9
+ * Build the wrap-up reminder for a task's OPEN boundary crossings (LUM-448).
10
+ * Returns the reminder string when there is ≥1 open crossing, and `null` only on
11
+ * a genuine empty read — the caller prints nothing on null, so a clean task
12
+ * makes NO noise at wrap time. A check FAILURE (LUM-480) is NOT silent: it
13
+ * returns a warning so a failed safety check never reads as "0 open / safe".
14
+ * Read-only awareness: the reminder points at the human-only web disposition
15
+ * panel and offers no way to clear a crossing from the terminal (LUM-426/435/422).
16
+ */
17
+ function formatCrossingReminder(taskIdentifier, result, url) {
18
+ if (result.status === 'error') {
19
+ return (`⚠ Could not check boundary crossings on ${taskIdentifier} ` +
20
+ `(network/server error) — unable to confirm whether any are still ` +
21
+ `undispositioned. Review in the web panel: ${url}\n`);
22
+ }
23
+ const open = result.crossings;
24
+ if (open.length === 0)
25
+ return null;
26
+ const n = open.length;
27
+ const lines = [
28
+ `⚠ ${n} open boundary crossing${n === 1 ? '' : 's'} on ${taskIdentifier} still undispositioned:`,
29
+ ];
30
+ for (const c of open) {
31
+ lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}`);
32
+ }
33
+ lines.push(` Review & disposition (web + human-only): ${url}`);
34
+ return lines.join('\n') + '\n';
35
+ }
36
+ /**
37
+ * Resolve the session's bound task and surface its OPEN boundary crossings as a
38
+ * wrap-up reminder (LUM-448), or `null` when the session is unbound or the read
39
+ * genuinely came back empty. A crossings-check failure yields a warning, not
40
+ * silence (LUM-480). Pure read — `fetchOpenCrossings` hits only the LUM-435 GET
41
+ * endpoint and there is no disposition write path here.
42
+ */
43
+ async function openCrossingReminder(creds) {
44
+ const taskIdentifier = await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(creds.apiUrl, creds.token);
45
+ if (!taskIdentifier)
46
+ return null;
47
+ const result = await (0, open_crossings_1.fetchOpenCrossings)(creds.apiUrl, creds.token, taskIdentifier);
48
+ return formatCrossingReminder(taskIdentifier, result, (0, open_crossings_1.dispositionUrl)(creds.apiUrl, creds.workspaceSlug, taskIdentifier));
49
+ }
@@ -44,7 +44,6 @@ const auth_logout_1 = require("./commands/auth-logout");
44
44
  const whoami_1 = require("./commands/whoami");
45
45
  const hook_1 = require("./commands/hook");
46
46
  const session_attach_1 = require("./commands/session-attach");
47
- const session_detach_1 = require("./commands/session-detach");
48
47
  const session_status_1 = require("./commands/session-status");
49
48
  const session_wrap_1 = require("./commands/session-wrap");
50
49
  const next_1 = require("./commands/next");
@@ -110,6 +109,7 @@ const doc_sync_1 = require("./commands/doc-sync");
110
109
  const doc_update_1 = require("./commands/doc-update");
111
110
  const doc_show_1 = require("./commands/doc-show");
112
111
  const doc_diff_1 = require("./commands/doc-diff");
112
+ const doc_rebuild_source_1 = require("./commands/doc-rebuild-source");
113
113
  const doc_section_edit_1 = require("./commands/doc-section-edit");
114
114
  const doc_list_1 = require("./commands/doc-list");
115
115
  const doc_delete_1 = require("./commands/doc-delete");
@@ -239,17 +239,12 @@ const session = program
239
239
  .description('Manage per-terminal coding-session context');
240
240
  session
241
241
  .command('attach <identifier>')
242
- .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. Sets Session.taskId server-side and re-tags untagged hook events. If the session is already bound to a different task, confirms before overwriting (use --force to skip).')
243
- .option('--force', 'Overwrite an existing binding to a different task without confirmation (skips the [y/N] prompt).')
244
- .action(wrap((identifier, options) => (0, session_attach_1.sessionAttach)(identifier, options)));
242
+ .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. The binding is a lifetime lock: re-attaching to the same task is a no-op, attaching to a different task is refused start a new session for a different task.')
243
+ .action(wrap(identifier => (0, session_attach_1.sessionAttach)(identifier)));
245
244
  session
246
245
  .command('status')
247
246
  .description('Show the task currently bound to this Claude Code session (or "no task" if none).')
248
247
  .action(wrap(() => (0, session_status_1.sessionStatus)()));
249
- session
250
- .command('detach')
251
- .description('Clear the task binding on the current Claude Code session. Past hook events keep their taskId; only future events become untagged.')
252
- .action(wrap(() => (0, session_detach_1.sessionDetach)()));
253
248
  session
254
249
  .command('wrap')
255
250
  .description("Session-end wrap-up: draft a progress comment from this session's turn summaries and post it to the bound task after confirmation.")
@@ -715,6 +710,13 @@ doc
715
710
  .description('Compare the server-side markdown source against a local file. Exit 0 when byte-identical, 1 with a unified diff when divergent. Requires the doc to have a stored markdown source.')
716
711
  .requiredOption('--file <path>', 'Local markdown file to compare against')
717
712
  .action(wrap((reference, opts) => (0, doc_diff_1.docDiff)(reference, opts)));
713
+ doc
714
+ .command('rebuild-source <doc>')
715
+ .description("Regenerate the stored markdown source from the doc's HTML body using a lossless serializer (tables/rows/headings round-trip), re-enabling doc show --raw / diff / patch / append for a source-less doc. The rebuilt source is structure-guarded: any table/tr/heading shrink is rejected with 422 (no silent flattening) unless --allow-shrink is passed. A doc that already has a source is refused with 409 unless --force re-derives it (replacing a byte-faithful source with a serializer-derived one).")
716
+ .option('--allow-shrink', 'Commit even if the rebuilt source re-renders with fewer tables/rows/headings than the stored body (default: rejected with 422)')
717
+ .option('--force', 'Re-derive the source even when one already exists (default: refused with 409)')
718
+ .option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
719
+ .action(wrap((reference, opts) => (0, doc_rebuild_source_1.docRebuildSource)(reference, opts)));
718
720
  doc
719
721
  .command('list')
720
722
  .description('List documents visible to the current user')
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchOpenCrossings = fetchOpenCrossings;
4
+ exports.dispositionUrl = dispositionUrl;
5
+ const api_1 = require("./api");
6
+ const SEVERITY_RANK = {
7
+ HIGH: 3,
8
+ MEDIUM: 2,
9
+ LOW: 1,
10
+ };
11
+ /** Narrow the raw view's attribution to the terminal's read-only shape, every
12
+ * dimension defaulting to null (unknown) when absent or the wrong type. */
13
+ function normalizeAttribution(raw) {
14
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : null;
15
+ return {
16
+ workspaceMemberId: str(raw?.workspaceMemberId),
17
+ sessionId: str(raw?.sessionId),
18
+ agent: str(raw?.agent),
19
+ worktreeBranch: str(raw?.worktreeBranch),
20
+ model: str(raw?.model),
21
+ };
22
+ }
23
+ function normalizeSeverity(s) {
24
+ return s === 'HIGH' || s === 'MEDIUM' || s === 'LOW' ? s : 'LOW';
25
+ }
26
+ /**
27
+ * Fetch a task's OPEN (undispositioned) boundary crossings, highest-severity
28
+ * first, via the EXISTING LUM-435 read endpoint — `GET …/boundary-crossings`
29
+ * returns every crossing (open and dispositioned); we keep only the
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).
34
+ *
35
+ * Fails *closed*, not open (LUM-480): any transport / non-ok HTTP / parse
36
+ * failure returns `{ status: 'error', reason }` so the caller can say "check
37
+ * failed — could not confirm" instead of mistaking a hiccup for "0 open / all
38
+ * clear". A successful read returns `{ status: 'ok', crossings }`; the list is
39
+ * empty only when the server genuinely reports no open crossings. Either way the
40
+ * supplementary safety signal never throws into the caller's primary output (the
41
+ * acceptance status, the wrap-up panel) — failure is a value, not an exception.
42
+ */
43
+ async function fetchOpenCrossings(apiUrl, token, taskIdentifier) {
44
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/tasks/${encodeURIComponent(taskIdentifier)}/boundary-crossings`;
45
+ let res;
46
+ try {
47
+ res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
48
+ }
49
+ catch (err) {
50
+ return {
51
+ status: 'error',
52
+ reason: err instanceof Error ? err.message : 'network error',
53
+ };
54
+ }
55
+ if (!res.ok)
56
+ return { status: 'error', reason: `HTTP ${res.status}` };
57
+ let data;
58
+ try {
59
+ data = (await res.json());
60
+ }
61
+ catch {
62
+ return { status: 'error', reason: 'invalid response body' };
63
+ }
64
+ const rows = Array.isArray(data.crossings) ? data.crossings : [];
65
+ const crossings = rows
66
+ .filter(c => c.disposition == null)
67
+ .map(c => ({
68
+ id: c.id,
69
+ category: c.category,
70
+ severity: normalizeSeverity(c.severity),
71
+ detail: c.detail,
72
+ attribution: normalizeAttribution(c.attribution),
73
+ }))
74
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
75
+ return { status: 'ok', crossings };
76
+ }
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 +
81
+ * identifier alone (the `/my-tasks/<id>` route needs no project slug), so no
82
+ * extra fetch is required.
83
+ */
84
+ function dispositionUrl(apiUrl, workspaceSlug, taskIdentifier) {
85
+ return `${(0, api_1.trimTrailingSlash)(apiUrl)}/workspace/${workspaceSlug}/my-tasks/${taskIdentifier}#boundary-crossings`;
86
+ }
@@ -93,10 +93,29 @@ function parseSectionQuery(raw) {
93
93
  return { text: (m[2] ?? '').trim(), depth: (m[1] ?? '').length };
94
94
  return { text: raw.trim() };
95
95
  }
96
+ /**
97
+ * Normalize a heading for the widest match tier (LUM-447): fold full-width
98
+ * ASCII forms (U+FF01–U+FF5E — the (),etc. that pepper CJK headings) down
99
+ * to their half-width counterparts, turn the full-width ideographic space
100
+ * (U+3000) into a normal space, collapse runs of whitespace to one, then
101
+ * lower-case. This lets a half-width `--section` query land on a full-width
102
+ * stored heading (and the reverse) so agents no longer have to grep and copy
103
+ * the raw bytes. It does NOT relax the ambiguity guard: matches are still
104
+ * counted, and more than one is reported as candidates.
105
+ */
106
+ function normalizeHeading(text) {
107
+ return text
108
+ .replace(/[!-~]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
109
+ .replace(/ /g, ' ')
110
+ .replace(/\s+/g, ' ')
111
+ .trim()
112
+ .toLowerCase();
113
+ }
96
114
  /**
97
115
  * Locate a section by heading text. Exact match first, then a
98
- * case-insensitive pass; more than one hit in the winning pass is reported
99
- * as ambiguous rather than silently picking one.
116
+ * case-insensitive pass, then a full-width/half-width + whitespace
117
+ * normalization pass (LUM-447); more than one hit in the winning pass is
118
+ * reported as ambiguous rather than silently picking one.
100
119
  */
101
120
  function findSection(src, query) {
102
121
  const q = parseSectionQuery(query);
@@ -107,6 +126,10 @@ function findSection(src, query) {
107
126
  const lower = q.text.toLowerCase();
108
127
  matches = pool.filter(s => s.heading.toLowerCase() === lower);
109
128
  }
129
+ if (matches.length === 0) {
130
+ const normalized = normalizeHeading(q.text);
131
+ matches = pool.filter(s => normalizeHeading(s.heading) === normalized);
132
+ }
110
133
  const first = matches[0];
111
134
  if (matches.length === 1 && first)
112
135
  return { kind: 'found', section: first };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",
@@ -1,60 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sessionDetach = sessionDetach;
4
- const config_1 = require("../lib/config");
5
- const api_1 = require("../lib/api");
6
- const sanitize_1 = require("../lib/sanitize");
7
- /**
8
- * `lumo session detach` — clear the task binding on the current Claude Code
9
- * session. Idempotent: re-detaching an already-unbound session reports
10
- * "already unbound" instead of erroring.
11
- *
12
- * Past HookEvent rows keep their original taskId — only future events on
13
- * this session will be unbound. Re-attach later with `session attach
14
- * <LUM-N>` to point the session at a different task.
15
- */
16
- async function sessionDetach() {
17
- const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
18
- if (!sessionId) {
19
- console.error('Error: $CLAUDE_CODE_SESSION_ID is not set.\n' +
20
- '`lumo session detach` must be run inside a Claude Code session.');
21
- return 1;
22
- }
23
- const creds = (0, config_1.readCredentials)();
24
- if (!creds) {
25
- console.error('Error: not logged in. Run `lumo auth login` first.');
26
- return 1;
27
- }
28
- const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
29
- const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
30
- let res;
31
- try {
32
- res = await fetch(url, {
33
- method: 'DELETE',
34
- headers: { Authorization: `Bearer ${creds.token}` },
35
- });
36
- }
37
- catch (err) {
38
- const msg = err instanceof Error ? err.message : String(err);
39
- console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
40
- return 1;
41
- }
42
- if (res.status === 401) {
43
- console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
44
- return 1;
45
- }
46
- if (res.status === 404) {
47
- process.stdout.write(`Session ${sessionId} has no server-side state yet — nothing to detach.\n`);
48
- return;
49
- }
50
- if (!res.ok) {
51
- console.error(`Error: session detach failed (HTTP ${res.status})`);
52
- return 1;
53
- }
54
- const data = (await res.json());
55
- if (data.alreadyUnbound) {
56
- process.stdout.write(`Session ${sessionId} was already unbound.\n`);
57
- return;
58
- }
59
- process.stdout.write(`Detached session ${sessionId} from ${data.previousTaskIdentifier} "${(0, sanitize_1.sanitizeField)(data.previousTaskTitle ?? '')}".\n`);
60
- }