@lumoai/cli 1.30.0 → 1.32.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.
@@ -80,6 +80,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
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
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.**
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`.
83
84
 
84
85
  **Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
85
86
 
@@ -96,6 +96,11 @@ cfl_xxx3 Onboarding (file-level) 2026-05-20 ⚠ thumbnail stale
96
96
 
97
97
  Accepts a `cfl_*` cuid or the original URL. Idempotent (`Not linked: ...` + exit 0 when no match).
98
98
 
99
+ ```bash
100
+ lumo task figma rm LUM-42 cfl_xxx1
101
+ lumo task figma rm LUM-42 "https://www.figma.com/design/abc123/Onboarding?node-id=1-234"
102
+ ```
103
+
99
104
  #### `lumo task figma refresh <task>` — manual refresh
100
105
 
101
106
  Re-fetches metadata + thumbnail for every Figma link on the task. Per-link
@@ -64,9 +64,84 @@ must track the size of the work — it is not a fixed ritual:
64
64
  HUMAN** — the server rejects checkpointer-less MACHINE criteria rather
65
65
  than silently rewriting them. Don't invent a fake checkpointer to keep it
66
66
  MACHINE.
67
+ - **Jest-by-name checkpointers must go through the zero-match guard.** A
68
+ bare `npx jest -t '<name>'` exits 0 even when the pattern matches **no**
69
+ test — rename or delete the test and the checkpointer silently fake-PASSes
70
+ (same trap class as the BSD-grep `-P` incident, LUM-401). Write
71
+ `npx tsx scripts/jest-t.ts '<exact test name>' [test file path]` instead:
72
+ it fails unless ≥1 matching test ran and passed. The optional path scopes
73
+ the run to one file (much faster than name-filtering the whole suite).
67
74
  - `evidenceRequired: true` marks criteria whose verdict must point at proof
68
75
  (a MACHINE PASS always requires evidence regardless of this flag).
69
76
 
77
+ ### Invariant (negative-assertion) criteria
78
+
79
+ Most criteria assert that **something that should happen, happened** ("the
80
+ endpoint returns 409", "the section renders"). An _invariant criterion_ asserts
81
+ the dual: that **something that must not happen, didn't** — the change left an
82
+ explicit don't-touch surface intact. This is the acceptance analogue of
83
+ AppWorld's _collateral-damage_ checks (arXiv 2407.18901) and ToolSandbox's
84
+ _minefield_ states (arXiv 2408.04682): a finished task should be judged not only
85
+ on the intended effect but on the forbidden effects it avoided. `lumo verify`
86
+ already judges final state per round, so an invariant fits the existing
87
+ machinery with no new mechanism — it's a drafting habit, not a feature.
88
+
89
+ **When to write one.** Add a single invariant criterion when the task has a
90
+ concrete, nameable surface the change must not disturb — a data-destruction red
91
+ line, a structure guard, an always-green baseline set, a diff that must stay
92
+ in-bounds, a standing budget. It is decision support, not a ritual: reach for it
93
+ only when you can point at the surface. If the only invariant you can name is
94
+ "don't break the build", that's already the repo's PR baseline (tests / `tsc` /
95
+ lint / i18n parity) — don't spend a slot restating it.
96
+
97
+ **How to phrase it.** State the invariant as _still holding after the change_,
98
+ not as a step you took. The **c-CRAB boundary** is the hard rule: the check
99
+ encodes **"the problem does not (re)occur"**, never **"a specific fix exists"**.
100
+ Write "`prisma/migrations/` has no deleted files vs origin/main" (the bad state
101
+ is absent) — not "the migration-delete guard function is present" (a named fix).
102
+ The first survives a refactor of _how_ the invariant is enforced; the second
103
+ re-fails the moment someone renames the guard, and passes even if the protection
104
+ was gutted some other way.
105
+
106
+ **Pairing with a checkpointer.** An invariant almost always has a runnable check
107
+ — that's its advantage — so prefer MACHINE. The existing `checkpointer` syntax
108
+ carries it as-is: a `git diff` path/filter probe, a full-suite `jest` run, a
109
+ structure-verify script, a parity check. Two real-repo examples:
110
+
111
+ - **`prisma/migrations/` files are never deleted** (the CLAUDE.md red line). The
112
+ bad state is a deleted migration file in the diff:
113
+
114
+ ```json
115
+ {
116
+ "statement": "No file under prisma/migrations/ is deleted by this change (vs origin/main)",
117
+ "verifierType": "MACHINE",
118
+ "checkpointer": "bash -c \"test -z \\\"$(git diff --diff-filter=D --name-only origin/main -- prisma/migrations/)\\\"\""
119
+ }
120
+ ```
121
+
122
+ - **A live-doc's table structure is not flattened** (the LUM-349 incident: an
123
+ HTML→md round-trip silently collapsed tables to plain text). The verify script
124
+ hard-fails if any table / row / heading count shrank:
125
+
126
+ ```json
127
+ {
128
+ "statement": "Live-doc keeps its table structure after the edit (no rows/headings dropped)",
129
+ "verifierType": "MACHINE",
130
+ "checkpointer": "npx tsx scripts/verify-live-doc.ts <docId> docs/live-docs/<file>.md"
131
+ }
132
+ ```
133
+
134
+ A third lives in a current contract you can copy: **LUM-415**'s "SKILL.md
135
+ frontmatter description stays ≤ 1000 chars" — the standing context-budget
136
+ invariant (the description is resident in every session), checkpointed by the
137
+ `description cap` case in
138
+ `scripts/analysis/lum392-cli-friction/__tests__/doc-examples.test.ts`.
139
+
140
+ One invariant criterion is usually enough — it's the guardrail, not the whole
141
+ contract. Pair it with the positive criteria that say what the change should
142
+ achieve; together they assert _did the right thing_ **and** _touched nothing
143
+ it shouldn't_.
144
+
70
145
  ## JSON file format
71
146
 
72
147
  `criteria.json` is a JSON array; one object per criterion:
@@ -104,6 +179,13 @@ Rejected with 409 once **any** criteria exist on the task — the agent lock.
104
179
  Echoes the stored criteria (with ids) plus the 3–7 soft-cap warning if
105
180
  outside range.
106
181
 
182
+ ```bash
183
+ lumo task criteria set LUM-42 --file criteria-lum42.json
184
+ ```
185
+
186
+ (`--file` must point inside the current project directory — write the JSON to
187
+ the repo root or a subdirectory, not `/tmp`, and delete it after submission.)
188
+
107
189
  ### `lumo task criteria set <task> --file <criteria.json> --human`
108
190
 
109
191
  Record a **human contract revision** (HUMAN_EDIT), e.g. when the user changes
@@ -125,6 +207,11 @@ verdict is never** — human verdicts only enter through human-initiated paths
125
207
  Only use `--human` for decisions a human actually made (in conversation, in a
126
208
  comment, in review). Never use it to work around your own lock.
127
209
 
210
+ ```bash
211
+ lumo task criteria set LUM-42 --file criteria-revision.json --human
212
+ lumo task criteria set LUM-42 --file criteria-revision.json --human --cause SCOPE_CHANGE
213
+ ```
214
+
128
215
  Optionally annotate **why** the contract drifted with
129
216
  `--cause <NEW_INFO|SCOPE_CHANGE|DRAFT_BLIND_SPOT|GRANULARITY|OTHER>` — pick
130
217
  the tag from the human's stated reason (new information, scope moved, the
@@ -139,6 +226,10 @@ Print the contract: `<id> [MACHINE|HUMAN] SOURCE@rN [evidence] statement`
139
226
  plus an indented `↳ check:` line for checkpointers. Use it to fetch ids
140
227
  before a `--human` revision. Empty contract prints a drafting pointer.
141
228
 
229
+ ```bash
230
+ lumo task criteria list LUM-42
231
+ ```
232
+
142
233
  ## Injection behavior
143
234
 
144
235
  - **Session start** (bound task): the contract is the highest-priority
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Document Management
4
4
 
5
+ ### Content channels: prefer stdin / `--content`
6
+
7
+ `doc create` / `doc update` / `doc patch` / `doc append` all take the body from one of three mutually-exclusive channels. **For agent write-back, prefer stdin or `--content`** — pipe the markdown you already hold in memory, or pass it inline:
8
+
9
+ ```bash
10
+ printf '%s' "$body" | lumo doc update cmd_xxx # stdin — best for multi-line / generated content
11
+ lumo doc append cmd_xxx --section "F 待办队列" --content "- [ ] 评估 XYZ 论文" # --content — short inline edits
12
+ ```
13
+
14
+ `--file` is the **fallback for content that already lives in an authoritative file on disk** (e.g. a repo-tracked `docs/live-docs/<file>.md` source you are editing). It is **not** the default channel: `--file` is sandboxed — the CLI rejects any path outside the current project directory or matching the sensitive-file denylist (`.env*`, keys, `credentials`, …), with no override. So feeding it generated content forces you to first write a scratch file _inside the repo_ just to pass it — an extra step that is the real-world friction that gets `--file` rejected. Pipe via stdin instead and skip the temp file; reach for `--file` only when the file _is_ the source of truth you're editing.
15
+
16
+ ### Markdown tables: keep them compact (no alignment padding)
17
+
18
+ When you author or edit a markdown doc that contains tables (live-docs, registers, reports), write the cells **compact — one space around each pipe, no column-alignment padding**:
19
+
20
+ ```
21
+ | col | meaning |
22
+ | --- | --- |
23
+ | a | first |
24
+ ```
25
+
26
+ not the aligned form (`| col | meaning |` padded so columns line up). Two reasons, both about local editability:
27
+
28
+ - **prettier re-pads tables.** A compact table gets re-aligned and a padded one churns the diff on every prettier run — so repo-tracked live-doc sources under `docs/live-docs/` are in `.prettierignore` to stay byte-stable (see the live-docs README).
29
+ - **the Edit tool's exact-match fails on padding.** Incremental edits match on exact cell text; alignment whitespace makes that match fragile (measured to fail). Compact cells edit reliably.
30
+
31
+ This is a pure authoring convention — the server stores your markdown **byte-for-byte** (`sourceMarkdown`), so `doc show --raw` and `doc diff` stay byte-exact (LUM-408); there is **no** server-side table normalization to lean on. The structure guard (LUM-410) compares _rendered_ structure and is whitespace-insensitive, so compactness buys local editability, not a verify pass.
32
+
5
33
  ### `lumo doc create [title] [flags]` — create a new document
6
34
 
7
35
  Use this when the user wants to write a new document from the terminal. Title is positional and optional. When omitted, the doc title defaults to empty (server renders as "Untitled" via i18n).
@@ -132,12 +160,12 @@ Use default mode when the user wants to read a doc; use `--raw` whenever the ful
132
160
 
133
161
  Replaces the **whole addressed section** (heading line included, subsections included) with the provided content, server-side, leaving every byte outside the section untouched. The new content comes from `--content`, `--file`, or piped stdin (one required; `--file` is sandboxed like `doc update`).
134
162
 
135
- | Flag | Type | Notes |
136
- | --------------------- | ------ | --------------------------------------------------------------------------------------------------- |
137
- | `--section <heading>` | string | Required. Heading text addressing the section; prefix `#…` pins the depth. |
138
- | `--content <text>` | string | New section content (include the heading line — the whole section is replaced verbatim). |
139
- | `--file <path>` | string | New section content from file (project-local sandbox). |
140
- | `--if-revision <n>` | int | Only apply if the body is still at revision `n`. Recommended whenever the edit base was read earlier. |
163
+ | Flag | Type | Notes |
164
+ | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
165
+ | `--section <heading>` | string | Required. Heading text addressing the section; prefix `#…` pins the depth. |
166
+ | `--content <text>` | string | New section content (include the heading line — the whole section is replaced verbatim). |
167
+ | `--file <path>` | string | New section content from file (project-local sandbox). |
168
+ | `--if-revision <n>` | int | Only apply if the body is still at revision `n`. Recommended whenever the edit base was read earlier. |
141
169
  | `--allow-shrink` | boolean | Let the patch through even when it drops tables/rows/headings within the addressed section (422 otherwise). |
142
170
 
143
171
  Concurrency: the splice always commits **conditionally** on the revision the server read the source at — even without `--if-revision`, a concurrent body edit between read and write returns 409 instead of clobbering. On 409 the CLI prints the server reason plus a re-read-and-retry hint and exits 1.
@@ -164,11 +192,11 @@ lumo doc patch cmd_xxx --section "D 状态表" --file sec.md --if-revision 6
164
192
 
165
193
  Inserts the new content at the **end of the addressed section** (just before the next same-or-higher-level heading), or at the end of the document when `--section` is omitted. Pure insertion: no pre-existing byte is modified, which makes it the natural write for running logs, ledgers and queues. Content channels and sandbox are the same as `doc patch`; separator blank lines are added automatically.
166
194
 
167
- | Flag | Type | Notes |
168
- | --------------------- | ------ | --------------------------------------------------------------------------- |
169
- | `--section <heading>` | string | Optional. Omit to append at the document end. |
170
- | `--content <text>` | string | Content to append. |
171
- | `--file <path>` | string | Content from file (project-local sandbox). |
195
+ | Flag | Type | Notes |
196
+ | --------------------- | ------ | ---------------------------------------------------------------------------- |
197
+ | `--section <heading>` | string | Optional. Omit to append at the document end. |
198
+ | `--content <text>` | string | Content to append. |
199
+ | `--file <path>` | string | Content from file (project-local sandbox). |
172
200
  | `--if-revision <n>` | int | Only apply if the body is still at revision `n`; 409 + retry hint otherwise. |
173
201
 
174
202
  Same concurrency contract as `doc patch` (always a conditional commit; 409 on conflict). Requires a stored markdown source.
@@ -188,8 +216,8 @@ echo "## 2026-06-10\n吸收了 3 篇" | lumo doc append cmd_xxx # document end
188
216
 
189
217
  Compares the server-side stored markdown source (the byte-identical last markdown upload) against a local file, making remote/local split-brain visible on demand.
190
218
 
191
- | Flag | Type | Notes |
192
- | --------------- | ------ | ---------------------------------------------------------------------------------- |
219
+ | Flag | Type | Notes |
220
+ | --------------- | ------ | --------------------------------------------------------------------------------- |
193
221
  | `--file <path>` | string | Required. Local markdown file; same project-local sandbox as `doc update --file`. |
194
222
 
195
223
  Exit codes: **0** = byte-identical (prints `Clean: …`), **1** = divergent (prints a unified diff, `--- remote/<id> (sourceMarkdown)` vs `+++ local/<file>`) or error. A doc without a stored markdown source errors explicitly (upload a markdown base once, then diff works).
@@ -278,6 +306,10 @@ lumo doc delete cmd_xxx --yes
278
306
 
279
307
  Adds an explicit DocumentMention row. Survives content edits in the Web UI. If a CONTENT-derived mention already exists for the same pair, it's upgraded to EXPLICIT so a later `unbind` can remove it.
280
308
 
309
+ ```bash
310
+ lumo doc bind cmd_xxx LUM-42
311
+ ```
312
+
281
313
  Output:
282
314
 
283
315
  ```
@@ -19,7 +19,7 @@ lumo task memory add [LUM-N] --category trap --trigger "..." --outcome "..." [--
19
19
  lumo task memory add [LUM-N] --category decision --what "..." --why "..." [--alternatives "..."] [--implications "..."] [--agent <agent>]
20
20
  lumo task memory add [LUM-N] --category convention --rule "..." --applies "..." [--agent <agent>]
21
21
  lumo task memory add [LUM-N] --category procedural --workflow "..." --trigger "..." [--step "..." --step "..."] [--agent <agent>]
22
- lumo project memory add [<project>] --category ... [--agent <agent>] # same flags; records at PROJECT scope
22
+ lumo project memory add [<project>] --category convention --rule "..." --applies "..." [--agent <agent>] # same per-category flags as task memory add; records at PROJECT scope
23
23
 
24
24
  # --agent values: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (default claude-code)
25
25
  # Aliases: gemini → gemini-cli, copilot → github-copilot (case-insensitive)
@@ -34,6 +34,23 @@ When the session is bound (`lumo session attach <LUM-N>`), omit the identifier:
34
34
  `lumo task memory add --category trap --trigger ... --outcome ...` records onto
35
35
  the bound task; `lumo project memory add ...` records onto its project.
36
36
 
37
+ Real calls (memory fields are structured per category — there is **no**
38
+ `--content` flag):
39
+
40
+ ```bash
41
+ # Minimal — session bound, identifier omitted
42
+ lumo task memory add --category trap --trigger "npx tsc --noEmit passes locally but CI fails on exactOptionalPropertyTypes" --outcome "PR goes red after merge"
43
+ lumo project memory add --category convention --rule "CLI tag-modify commands print a final 'Tags:' line" --applies "lumo doc/task create and update"
44
+
45
+ # Full form — explicit identifier, all optional fields
46
+ lumo task memory add LUM-42 --category decision --what "Store doc content as Tiptap-serialized HTML" --why "markdown-to-JSON and markdown-to-HTML paths drifted" --alternatives "store Tiptap JSON" --implications "all markdown input goes through markdownToHtml" --agent claude-code
47
+ lumo project memory add lumo --category procedural --workflow "Regenerate the CLI grammar snapshot" --trigger "any cli/src/commands change" --step "npx tsx scripts/analysis/lum392-cli-friction/emit-grammar.ts" --step "npx jest scripts/analysis/lum392-cli-friction" --agent claude-code
48
+
49
+ # Curate
50
+ lumo memory promote cmpi19iqabc123
51
+ lumo memory rm cmpi19iqabc123 --yes
52
+ ```
53
+
37
54
  ### Reconcile-on-write & deduplication
38
55
 
39
56
  `memory add` does **not** unconditionally insert a new row. Before writing it:
@@ -22,8 +22,8 @@ not touch git hooks.
22
22
  npx @lumoai/cli setup # interactive: prompts user/project; agent=claude-code
23
23
  npx @lumoai/cli setup --user # write to ~/.claude/
24
24
  npx @lumoai/cli setup --project # write to ./.claude/
25
- npx @lumoai/cli setup --force # overwrite skill files if they differ from bundled
26
- npx @lumoai/cli setup --agent codex # bake agent=codex into the hook commands
25
+ lumo setup --project --force # same via global install: overwrite skill files if they differ from bundled
26
+ lumo setup --project --agent codex # bake agent=codex into the hook commands
27
27
  ```
28
28
 
29
29
  Use when:
@@ -115,6 +115,11 @@ lumo task pr show LUM-42 128
115
115
  Read-only audit view over the task's `LineageEdge` rows. Given a task
116
116
  identifier (`LUM-N`), prints the causal trail:
117
117
 
118
+ ```bash
119
+ lumo task lineage LUM-42 # per-session causal trail + cost
120
+ lumo task lineage LUM-42 --signal # append workspace-level usage signal-health
121
+ ```
122
+
118
123
  - **Totals banner** — distinct sessions, fragment count, edge count,
119
124
  total tokens (input/output/cache split) and loops, and the outcome
120
125
  distribution.
@@ -154,6 +154,13 @@ LUM-48 TODO MEDIUM backend Investigate slow query
154
154
  | `-m, --milestone <ref>` | string | Filter to my tasks under this milestone (name or UUID). |
155
155
  | `-n, --limit <count>` | integer | Cap output to the first N rows. |
156
156
 
157
+ ```bash
158
+ lumo task list # everything assigned to me
159
+ lumo task list --status in_progress -n 10 # capped, status-filtered
160
+ lumo task list --project backend --status todo
161
+ lumo task list --milestone "Q3 Launch"
162
+ ```
163
+
157
164
  When `--milestone` is set, the CLI calls `GET /api/milestones/<id>/tasks?assigned=me` instead of `/api/tasks/me`. Other filters (`--status`, `--limit`) still apply client-side to the milestone-scoped result.
158
165
 
159
166
  Filtering is currently client-side — the server returns the full "my tasks" set and the CLI slices it. This is fine for typical workspaces (dozens of tasks); revisit if a workspace has hundreds.
@@ -146,3 +146,51 @@ major; additive fields don't. Pin on `version` when scripting against it.
146
146
  `status` reads; `verify` judges. Running status never starts a round, never
147
147
  escalates, and never changes task state — loop rules (cap 3, IN_REVIEW on
148
148
  all-pass, human-only DONE) live entirely in `lumo verify` and the server.
149
+
150
+ ## lumo verdict — the three verdict channels (LUM-422)
151
+
152
+ `lumo verify` is the MACHINE channel. `lumo verdict` covers the other two — the
153
+ HUMAN pass and the AGENT send-back — under one red line: **no passing data row
154
+ is ever agent-produced.**
155
+
156
+ ```
157
+ lumo verdict --pass
158
+ lumo verdict LUM-42 --pass-with-followup
159
+ lumo verdict --fail --reason CRITERION_UNMET --note "the retry path is still missing"
160
+ lumo verdict LUM-42 --fail --reason scope_mismatch --criterion c-abc123
161
+ ```
162
+
163
+ ### --pass / --pass-with-followup — a deep link, never a write
164
+
165
+ These resolve the task, then open the browser to its verdict bar focused on the
166
+ passing action. **The CLI writes nothing** — PASS / PASS_WITH_FOLLOWUP only ever
167
+ land from a human's own click (Clerk session). Use this to hand a finished task
168
+ to a human for the final pass; it carries them one click from recording it.
169
+
170
+ ### --fail — the AGENT send-back
171
+
172
+ `--fail --reason <enum>` records a real verdict row server-side with
173
+ verifierType=AGENT (a channel distinct from MACHINE and HUMAN, so "machine
174
+ all-pass but human FAIL" stays an uncontaminated signal). The verdict is
175
+ hard-coded FAIL — there is no agent path to a passing verdict. It:
176
+
177
+ - requires `--reason` (case-insensitive): `CRITERION_UNMET | EVIDENCE_INSUFFICIENT
178
+ | CHECK_EXECUTION_ERROR | SCOPE_MISMATCH | OTHER` — the agent pays the
179
+ structured tax a human send-back is spared;
180
+ - takes an optional `--note`, posted as a task comment (@mentions and images for
181
+ free) and summarized onto the verdict row;
182
+ - takes repeatable `--criterion <id>` to narrow the send-back; omitted, it fans
183
+ out to the whole contract;
184
+ - records at round = the current max (not a new round) and bounces the task back
185
+ to IN_PROGRESS, with the unmet criteria surfacing through `lumo task status`.
186
+
187
+ ### The DONE gate
188
+
189
+ Once any criterion's latest verdict is FAIL — machine, AGENT, or human — moving
190
+ the task to DONE on the agent/CLI path is refused with **409** and the
191
+ unresolved items listed. Clear the send-back (fix + re-verify, or a human PASS)
192
+ before `lumo task update <id> --status done`. A task with no criteria, or whose
193
+ criteria were never adjudicated, transitions freely — the gate only blocks an
194
+ actual send-back, never an un-adjudicated criterion. When the machine loop has
195
+ left a task IN_REVIEW with no send-back standing, the agent may move it to DONE
196
+ directly; a human-PASS row is a provable manual override, not a required ticket.
@@ -10,9 +10,11 @@ there); `list` is read-only and runnable from anywhere.
10
10
 
11
11
  Creates `.worktrees/<LUM-N>[-slug]` on branch `lumo/<LUM-N>[-slug]`, branched off
12
12
  `origin/main` (after a fetch), and symlinks its `node_modules` to the main
13
- checkout so jest/tsc work immediately. With no slug, the dir and branch use the
14
- bare `LUM-N` form. Prints next-step guidance plus the two gotchas it can't fix
15
- for you (shared prisma client, jest cwd).
13
+ checkout so jest/tsc work immediately. In the same scaffold step it copies the
14
+ main checkout's `.husky/_` hooks shim into the worktree so git hooks actually
15
+ fire there (see below). With no slug, the dir and branch use the bare `LUM-N`
16
+ form. Prints next-step guidance plus the two gotchas it can't fix for you
17
+ (shared prisma client, jest cwd).
16
18
 
17
19
  ```bash
18
20
  lumo worktree add LUM-267 worktree-scaffold # → .worktrees/LUM-267-worktree-scaffold
@@ -36,6 +38,19 @@ do `generate + tsc` atomically once at the end.
36
38
  in first) — `cli/` has no jest config, and running from the main checkout hits
37
39
  the `cli/package.json` haste collision and silently runs the wrong tests.
38
40
 
41
+ **The husky hooks it copies in:** husky owns git hooks via
42
+ `core.hooksPath = .husky/_`, a relative path git resolves against each
43
+ worktree's own root. That `_` shim dir is **untracked** (husky regenerates it on
44
+ the main checkout's `npm install`/`prepare`), so a fresh worktree would lack it
45
+ and git would **silently skip every hook** — pre-commit (lint-staged) and
46
+ commit-msg (LUM-405 drift-check) included. Since this repo has no GitHub CI,
47
+ husky is the only deterministic quality gate, so `add` copies `.husky/_` into the
48
+ new worktree (copy, not symlink — the shim is tiny and the hooks it invokes
49
+ resolve relative to the worktree). If the main checkout itself has no `.husky/_`
50
+ (husky never installed there), `add` prints a warning telling you to run
51
+ `npm install` in the main checkout to regenerate it, rather than skipping
52
+ silently.
53
+
39
54
  **Never run `npm install` / `npm ci` inside a worktree.** The worktree shares
40
55
  the main checkout's `node_modules` via a symlink; npm does not respect the link
41
56
  — it deletes it and reifies a full standalone `node_modules` (thousands of
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectCriterion = collectCriterion;
4
+ exports.verdict = verdict;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const sanitize_1 = require("../lib/sanitize");
8
+ const browser_1 = require("../lib/browser");
9
+ /**
10
+ * Rejection-reason vocabulary (mirrors the server's VerificationRejectionReason
11
+ * enum). Required on the agent --fail path — the agent pays the structured tax
12
+ * a human is spared (LUM-422 ①).
13
+ */
14
+ const FAIL_REASONS = [
15
+ 'CRITERION_UNMET',
16
+ 'EVIDENCE_INSUFFICIENT',
17
+ 'CHECK_EXECUTION_ERROR',
18
+ 'SCOPE_MISMATCH',
19
+ 'OTHER',
20
+ ];
21
+ /** Collect repeatable `--criterion <id>` flags into an array (commander idiom). */
22
+ function collectCriterion(value, prev = []) {
23
+ return [...prev, value];
24
+ }
25
+ /**
26
+ * `lumo verdict [task]` — human + agent acceptance verdicts (LUM-422).
27
+ *
28
+ * Three modes, exactly one required:
29
+ * --pass / --pass-with-followup open the browser to the task's verdict bar,
30
+ * focused on the passing action (a deep link — writes NOTHING; a passing
31
+ * data row is only ever produced by a human's own click, red line).
32
+ * --fail --reason <enum> [--note] [--criterion …] records an AGENT send-back
33
+ * (verdict hard-coded FAIL server-side) and bounces the task to
34
+ * IN_PROGRESS. Bearer-authed.
35
+ *
36
+ * Defaults to the session-bound task; an explicit identifier overrides.
37
+ */
38
+ async function verdict(identifier, options = {}) {
39
+ const modes = [
40
+ options.pass && 'pass',
41
+ options.passWithFollowup && 'pass-with-followup',
42
+ options.fail && 'fail',
43
+ ].filter(Boolean);
44
+ if (modes.length === 0) {
45
+ console.error('Error: choose a verdict mode — --pass, --pass-with-followup, or --fail.');
46
+ return 1;
47
+ }
48
+ if (modes.length > 1) {
49
+ console.error(`Error: pick exactly one verdict mode (got ${modes.join(', ')}).`);
50
+ return 1;
51
+ }
52
+ const creds = (0, config_1.readCredentials)();
53
+ if (!creds) {
54
+ console.error('Error: not logged in. Run `lumo auth login` first.');
55
+ return 1;
56
+ }
57
+ const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
58
+ const headers = {
59
+ Authorization: `Bearer ${creds.token}`,
60
+ };
61
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
62
+ if (sessionId)
63
+ headers['X-Lumo-Session-Id'] = sessionId;
64
+ // ── Resolve the task: explicit identifier or the session binding ──────────
65
+ let taskId = identifier;
66
+ if (!taskId) {
67
+ if (!sessionId) {
68
+ console.error('Error: no task given and $CLAUDE_CODE_SESSION_ID is not set.\n' +
69
+ 'Run `lumo verdict <LUM-N> …` or run inside a session bound via `lumo session attach`.');
70
+ return 1;
71
+ }
72
+ let res;
73
+ try {
74
+ res = await fetch(`${base}/api/sessions/${encodeURIComponent(sessionId)}`, { headers });
75
+ }
76
+ catch (err) {
77
+ const msg = err instanceof Error ? err.message : String(err);
78
+ console.error(`Error: could not reach Lumo API (${msg})`);
79
+ return 1;
80
+ }
81
+ const data = res.ok
82
+ ? (await res.json())
83
+ : null;
84
+ if (!data?.taskIdentifier) {
85
+ console.error('Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first, or pass the task explicitly.');
86
+ return 1;
87
+ }
88
+ taskId = data.taskIdentifier;
89
+ }
90
+ if (options.fail) {
91
+ return failVerdict(base, headers, taskId, options, creds.workspaceSlug);
92
+ }
93
+ return passDeepLink(base, headers, taskId, options.passWithFollowup ? 'pass_with_followup' : 'pass', creds.workspaceSlug);
94
+ }
95
+ /**
96
+ * --pass / --pass-with-followup: open the human's verdict bar pre-focused on the
97
+ * passing action. The CLI never writes the verdict — it only carries the human
98
+ * to the one click that does (red line: no agent-produced passing row).
99
+ */
100
+ async function passDeepLink(base, headers, taskId, verdictParam, workspaceSlug) {
101
+ let res;
102
+ try {
103
+ res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(taskId)}`, { headers });
104
+ }
105
+ catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ console.error(`Error: could not reach Lumo API (${msg})`);
108
+ return 1;
109
+ }
110
+ if (res.status === 401) {
111
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
112
+ return 1;
113
+ }
114
+ if (res.status === 404) {
115
+ console.error(`Error: task ${taskId} not found in workspace ${workspaceSlug}`);
116
+ return 1;
117
+ }
118
+ if (!res.ok) {
119
+ console.error(`Error: could not load task (HTTP ${res.status})`);
120
+ return 1;
121
+ }
122
+ const { task } = (await res.json());
123
+ if (!task?.url) {
124
+ console.error('Error: server did not return a task URL to open.');
125
+ return 1;
126
+ }
127
+ const sep = task.url.includes('?') ? '&' : '?';
128
+ const deepLink = `${task.url}${sep}verdict=${verdictParam}`;
129
+ const label = verdictParam === 'pass_with_followup' ? 'Pass with follow-up' : 'Pass';
130
+ process.stdout.write(`Opening ${taskId} for a human "${label}" verdict (nothing is recorded until they click):\n` +
131
+ ` ${(0, sanitize_1.sanitizeField)(deepLink)}\n`);
132
+ (0, browser_1.openBrowser)(deepLink);
133
+ return;
134
+ }
135
+ /**
136
+ * --fail: record an AGENT send-back. The server hard-codes verdict=FAIL and
137
+ * verifierType=AGENT — there is no passing verdict the CLI can express.
138
+ */
139
+ async function failVerdict(base, headers, taskId, options, workspaceSlug) {
140
+ if (!options.reason) {
141
+ console.error(`Error: --fail requires --reason <${FAIL_REASONS.join('|')}>.`);
142
+ return 1;
143
+ }
144
+ // Case-insensitive like every other CLI enum flag (LUM-420): fold to the
145
+ // canonical upper-case enum before validating and sending.
146
+ const reason = options.reason.toUpperCase();
147
+ if (!FAIL_REASONS.includes(reason)) {
148
+ console.error(`Error: invalid --reason "${options.reason}". Allowed: ${FAIL_REASONS.join(', ')}.`);
149
+ return 1;
150
+ }
151
+ const body = { rejectionReasonEnum: reason };
152
+ if (options.note)
153
+ body.note = options.note;
154
+ if (options.criterion && options.criterion.length > 0) {
155
+ body.criterionIds = options.criterion;
156
+ }
157
+ let res;
158
+ try {
159
+ res = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}/agent-verdict`, {
160
+ method: 'POST',
161
+ headers: { ...headers, 'Content-Type': 'application/json' },
162
+ body: JSON.stringify(body),
163
+ });
164
+ }
165
+ catch (err) {
166
+ const msg = err instanceof Error ? err.message : String(err);
167
+ console.error(`Error: could not reach Lumo API (${msg})`);
168
+ return 1;
169
+ }
170
+ if (res.status === 401) {
171
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
172
+ return 1;
173
+ }
174
+ if (res.status === 404) {
175
+ console.error(`Error: task ${taskId} not found in workspace ${workspaceSlug}`);
176
+ return 1;
177
+ }
178
+ if (!res.ok) {
179
+ const errBody = (await res.json().catch(() => null));
180
+ const detail = errBody && typeof errBody.error === 'string'
181
+ ? (0, sanitize_1.sanitizeField)(errBody.error)
182
+ : '';
183
+ console.error(`Error: send-back rejected (HTTP ${res.status})${detail ? ` — ${detail}` : ''}`);
184
+ return 1;
185
+ }
186
+ const outcome = (await res.json());
187
+ process.stdout.write(`✗ Sent ${taskId} back (AGENT FAIL, reason ${reason}) — ` +
188
+ `${outcome.criterionIds.length} ${outcome.criterionIds.length === 1 ? 'criterion' : 'criteria'} at round ${outcome.round}; task is now ${outcome.taskStatus}.\n`);
189
+ if (outcome.commentId) {
190
+ process.stdout.write(' A send-back note was posted as a task comment.\n');
191
+ }
192
+ process.stdout.write('Address the unmet criteria (see `lumo task status`), then re-verify.\n');
193
+ return;
194
+ }
@@ -96,6 +96,23 @@ async function worktreeAdd(rawId, slug, opts) {
96
96
  else {
97
97
  console.log('Warning: main checkout has no node_modules; run `npm install` there, then symlink manually');
98
98
  }
99
+ // husky owns git hooks via `core.hooksPath = .husky/_` (a relative path git
100
+ // resolves against each worktree's root). That `_` shim dir is untracked
101
+ // (generated by husky on the main checkout's `npm install`/`prepare`), so a
102
+ // fresh worktree lacks it and git SILENTLY skips every hook — pre-commit
103
+ // (lint-staged) and commit-msg (LUM-405 drift-check) included. This repo has
104
+ // no CI, so husky is the only deterministic gate; copy the shim in so hooks
105
+ // fire. Copy (not symlink) is the stable choice: the shim is tiny, rarely
106
+ // changes, and the hooks it invokes resolve relative to the worktree.
107
+ const srcHusky = path.join(root, '.husky', '_');
108
+ if (fs.existsSync(srcHusky)) {
109
+ fs.cpSync(srcHusky, path.join(dir, '.husky', '_'), { recursive: true });
110
+ console.log(' git hooks: husky shim copied (pre-commit + commit-msg active)');
111
+ }
112
+ else {
113
+ console.log('Warning: main checkout has no .husky/_ shim; git hooks will NOT run in this worktree.');
114
+ console.log(' Run `npm install` in the main checkout to (re)generate husky, then re-run `lumo worktree add`.');
115
+ }
99
116
  printGuidance(dir, branch);
100
117
  if (opts.verify) {
101
118
  console.log('Running baseline jest …');
@@ -49,6 +49,7 @@ const session_status_1 = require("./commands/session-status");
49
49
  const session_wrap_1 = require("./commands/session-wrap");
50
50
  const next_1 = require("./commands/next");
51
51
  const verify_1 = require("./commands/verify");
52
+ const verdict_1 = require("./commands/verdict");
52
53
  const task_context_1 = require("./commands/task-context");
53
54
  const task_create_1 = require("./commands/task-create");
54
55
  const task_update_1 = require("./commands/task-update");
@@ -218,6 +219,16 @@ program
218
219
  .description('Machine verification loop (LUM-343): run every MACHINE criterion checkpointer locally, report structured verdicts to the server (round cap 3), and print next actions. All-pass moves the task to IN_REVIEW. Defaults to the session-bound task.')
219
220
  .option('--timeout <seconds>', 'Per-checkpointer timeout in seconds (default 600)')
220
221
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
222
+ program
223
+ .command('verdict [task]')
224
+ .description('Acceptance verdict (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 row is only ever a human click). --fail --reason <enum> records an AGENT send-back and bounces the task to IN_PROGRESS. Defaults to the session-bound task.')
225
+ .option('--pass', 'Open the verdict bar focused on Pass (human one-click; no write)')
226
+ .option('--pass-with-followup', 'Open the verdict bar focused on Pass with follow-up (human one-click; no write)')
227
+ .option('--fail', 'Record an AGENT send-back (verdict FAIL) — requires --reason')
228
+ .option('--reason <enum>', 'Rejection reason for --fail: CRITERION_UNMET | EVIDENCE_INSUFFICIENT | CHECK_EXECUTION_ERROR | SCOPE_MISMATCH | OTHER (case-insensitive)')
229
+ .option('--note <text>', 'Optional send-back narrative, posted as a task comment')
230
+ .option('--criterion <id>', 'Narrow a --fail to specific criteria (repeatable); omitted = the whole contract', verdict_1.collectCriterion)
231
+ .action(wrap((task, options) => (0, verdict_1.verdict)(task, options)));
221
232
  program
222
233
  .command('next')
223
234
  .description('Recommend the next task(s) to work on, ranked by priority, active sprint, and due date. Prints top N (default 3); pick one and run `session attach` + `task context`.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",