@lumoai/cli 1.50.0 → 1.52.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.
@@ -63,9 +63,9 @@ The command catalog below is a **map**: it lists every command grouped by domain
63
63
  **Tasks** — see [tasks.md](references/tasks.md)
64
64
 
65
65
  - `lumo task create <title> [flags]` — create a task. **Mid-task** (your session is bound to an in-flight task) it requires `--rework-of <id>` (redirects you to fix the existing task — creates nothing) or `--new-scope` (genuinely new, out-of-scope work). On a send-back, fix in place / amend the contract instead of spinning off a new task — see [verify.md](references/verify.md) and [criteria.md](references/criteria.md).
66
- - `lumo task update <id> [flags]` — patch status/title/priority/assignee/milestone/sprint/tags
66
+ - `lumo task update <id> [flags]` — patch status/title/priority/assignee/milestone/sprint/tags; `--claimable`/`--not-claimable` set the agent-claimable marker (LUM-646)
67
67
  - `lumo task list [flags]` — list tasks assigned to you
68
- - `lumo next [--count N]` — recommend the next task to work on (read-only)
68
+ - `lumo next [--count N] [--claimable]` — recommend the next task to work on (read-only). `--claimable` restricts to the autonomy boundary: only tasks that are claimable ∩ unblocked (F3 topology) ∩ within milestone budget (F2); old server degrades gracefully to the standard ranking (LUM-646)
69
69
  - `lumo task show <id>` — print one task's detail
70
70
  - `lumo task comment <id> <body>` — leave a comment
71
71
 
@@ -124,8 +124,10 @@ lumo fidelity show LUM-42 --json
124
124
  - `lumo milestone list/create/show/update/delete` — milestone CRUD (`show` includes a Sprint-coverage section)
125
125
  - `lumo milestone archive/unarchive` — soft-archive / restore
126
126
  - `lumo milestone add/remove <id> <task...>` — batch bind/unbind tasks
127
+ - `lumo milestone triage <id> <task...> --claimable|--not-claimable` — steward batch agent-claimable triage (LUM-646): mark in-milestone tasks (not) claimable; tasks not in the milestone are skipped. Feeds `lumo next --claimable`
127
128
  - `lumo milestone summary [--retry]` — AI retro
128
129
  - `lumo milestone reorder/move` — manual ordering
130
+ - `lumo milestone criteria set/list/verdict` — milestone-level **exit criteria** (LUM-643): the close contract, drafted & adjudicated (裁定) by the steward (LUM-640) or a human. Unresolved criteria (pending or NOT_MET) block `--status completed` **and** the LUM-624 auto-rollup with 409/fail-closed; human override is web-only (`?force=1`, stamped as a persistent trail). `verdict --met` on `EXTERNAL_FACT` requires `--evidence` (422); `PENDING_OUTCOME` can never be MET
129
131
 
130
132
  **Documents** — CRUD/sharing → [docs.md](references/docs.md); **editing live docs** (raw/section/patch/append/diff/rebuild) → [doc-editing.md](references/doc-editing.md)
131
133
 
@@ -157,7 +159,7 @@ lumo fidelity show LUM-42 --json
157
159
  **Sessions** — see [sessions.md](references/sessions.md)
158
160
 
159
161
  - `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`.
160
- - `lumo session attach --steward <milestone>` — bind this session to a **milestone** as a **STEWARD (governance) session** (LUM-640): plan & accept only. Governance passes (task create/update, criteria set incl. `--human`, deps confirm/dismiss, `verdict --fail` with session provenance, milestone add/remove, all reads); implementation is gated (`lumo verify` → 409 **without burning a round**; moving a task to in_progress assigned to yourself → 409); commits/PRs produced anyway are recorded as milestone-scoped boundary crossings (fail-closed; disposition stays web+human-only, no CLI clear path). Same lifetime lock: same milestone idempotent, any other attach (a task, or a different milestone) → 409; multiple stewards per milestone are fine. See [sessions.md](references/sessions.md)
162
+ - `lumo session attach --steward <milestone>` — bind this session to a **milestone** as a **STEWARD (governance) session** (LUM-640): plan & accept only. Governance passes (task create/update, criteria set incl. `--human`, deps confirm/dismiss, `verdict --fail` with session provenance, milestone add/remove, milestone triage (LUM-646 claimable), all reads); implementation is gated (`lumo verify` → 409 **without burning a round**; moving a task to in_progress assigned to yourself → 409); commits/PRs produced anyway are recorded as milestone-scoped boundary crossings (fail-closed; disposition stays web+human-only, no CLI clear path). Same lifetime lock: same milestone idempotent, any other attach (a task, or a different milestone) → 409; multiple stewards per milestone are fine. See [sessions.md](references/sessions.md)
161
163
  - `lumo session status` — show current binding (a task, or the milestone + STEWARD role)
162
164
  - End-of-session housekeeping is fully automatic (no command). On DONE the server runs three best-effort silent passes: Layer-1 memory curation, fragment-usage audit, and blocked-tag automation. See [sessions.md](references/sessions.md)
163
165
  - Git-suggest at session start (suggests `session attach`, never auto-binds) + Layer-2 project-memory review — see the reference
@@ -5,7 +5,11 @@
5
5
  Record and curate the long-term Memory that Claude reads on future sessions.
6
6
  Memories are scoped **TASK** (useful only for one task) or **PROJECT** (useful
7
7
  across the whole project). Automated extraction (layer1) and promotion (layer2,
8
- on task→done) already run; these commands are the manual override.
8
+ on task→done) already run; these commands are the manual override. A third,
9
+ **MILESTONE** scope is written automatically only (no manual command): when a
10
+ milestone closes and its retro is generated, the durable forward-looking
11
+ learnings are distilled into MILESTONE-scope memories and injected at the next
12
+ milestone's steward session start (LUM-647) — see [sessions.md](sessions.md).
9
13
 
10
14
  ### Commands
11
15
 
@@ -98,25 +98,92 @@ lumo milestone show 11111111-2222-3333-4444-555555555555
98
98
 
99
99
  ### `lumo milestone update <identifier>` — patch a milestone
100
100
 
101
- | Flag | Type | Notes |
102
- | ---------------------- | ------ | ---------------------------------------------------------------- |
103
- | `--project <ref>` | string | Required when identifier is a name and workspace has >1 project. |
104
- | `-n, --name <text>` | string | Cannot be empty. |
105
- | `-d, --description <>` | string | `--description ""` clears. |
106
- | `-s, --status <value>` | enum | `planned \| active \| completed \| cancelled`. |
107
- | `--start <date>` | string | `--start ""` clears. |
108
- | `--target <date>` | string | `--target ""` clears. |
101
+ | Flag | Type | Notes |
102
+ | ---------------------- | ------ | ------------------------------------------------------------------------------ |
103
+ | `--project <ref>` | string | Required when identifier is a name and workspace has >1 project. |
104
+ | `-n, --name <text>` | string | Cannot be empty. |
105
+ | `-d, --description <>` | string | `--description ""` clears. |
106
+ | `-s, --status <value>` | enum | `planned \| active \| completed \| cancelled`. |
107
+ | `--start <date>` | string | `--start ""` clears. |
108
+ | `--target <date>` | string | `--target ""` clears. |
109
+ | `--token-budget <n>` | int | Advisory token budget (LUM-644), positive integer; `--token-budget ""` clears. |
109
110
 
110
111
  At least one field required.
111
112
 
113
+ **Token budget is advisory only (LUM-644):** the web Overview's tokens KPI card shows burn vs budget, and when the MEASURED burn exceeds the budget an advisory alert appears in the Risk & pending queue. Nothing is ever blocked by it — no hard gate. Unmeasured burn reads as unknown, never as over/under budget.
114
+
115
+ **When to suggest**: when the user wants to cap/track token spend on a milestone ("给这个 milestone 设 token 预算 / budget"), or asks why the Overview shows an over-budget alert.
116
+
112
117
  **Status rolls up automatically (LUM-624):** milestone status follows task completion — PLANNED → ACTIVE when any task starts, → COMPLETED when every task is DONE (stamps `completedAt`, triggers the retro summary), and COMPLETED → ACTIVE when a non-DONE task (re)appears. A manual `--status planned/active/completed` that contradicts task reality gets reconciled back on the next task event or detail read; `--status cancelled` and archived milestones are human terminal choices the rollup never touches. You normally don't need `--status completed` by hand anymore.
113
118
 
119
+ **The close is gated by exit criteria (LUM-643):** when the milestone has exit criteria (see `milestone criteria` below), any row not adjudicated MET — unadjudicated (PENDING) **or** NOT_MET — refuses `--status completed` with **409**, and holds the LUM-624 auto-rollup fail-closed (all tasks DONE keeps the milestone ACTIVE, no retro fires). There is **no CLI/agent override**: the only escape hatch is a human closing via the web with an explicit override (`?force=1` + optional note), stamped on the milestone as a persistent trail (`completedOverride*` — who/when/note, never auto-cleared). To open the gate legitimately, adjudicate the contract with `lumo milestone criteria verdict`.
120
+
114
121
  ```bash
115
122
  lumo milestone update "Q3 Launch" --status active
116
123
  lumo milestone update "Q3 Launch" --target 2026-09-15
117
124
  lumo milestone update "Q3 Launch" --description ""
125
+ lumo milestone update "Q3 Launch" --token-budget 50000000
126
+ lumo milestone update "Q3 Launch" --token-budget ""
127
+ ```
128
+
129
+ ### `lumo milestone criteria set <identifier> --file <criteria.json> [--human]` — submit the exit contract
130
+
131
+ Milestone-level **exit criteria** (LUM-643) — the close contract, mirroring the task-level acceptance contract where it transfers. Key differences from `task criteria`: there is **no `verifierType` / no `checkpointer`** — exit criteria are **adjudicated (裁定)**, not machine-verified — and each item requires only `statement` + `referentKind`. The **steward session (LUM-640) is the intended drafting & adjudicating subject** (drafting is a governance action; session provenance is recorded on every row); `--human` records a transcribed HUMAN_EDIT revision instead.
132
+
133
+ The file is a JSON array:
134
+
135
+ ```json
136
+ [
137
+ {
138
+ "statement": "Every LUM-64x migration is applied on prod (migrate status clean)",
139
+ "referentKind": "EXTERNAL_FACT"
140
+ },
141
+ {
142
+ "statement": "All milestone read models render the new fields",
143
+ "referentKind": "AGENT_CONSTRUCTED_STATE"
144
+ }
145
+ ]
146
+ ```
147
+
148
+ Semantics: **full-group replace** — existing rows with a verdict are tombstoned (their adjudication trail survives for audit), verdict-free rows are hard-deleted. The contract **locks with 409 once the milestone is COMPLETED** (reopen to edit). Archived milestones also refuse with 409.
149
+
150
+ ```bash
151
+ lumo milestone criteria set "Q3 Launch" --file exit-criteria.json
152
+ lumo milestone criteria set "Q3 Launch" --file exit-criteria.json --human
153
+ ```
154
+
155
+ ### `lumo milestone criteria list <identifier>` — print the exit contract
156
+
157
+ One line per criterion — `<id> [MET|NOT_MET|PENDING] SOURCE ⟨REFERENT⟩ statement` — plus an indented `↳` detail line (date · evidence · note) for adjudicated rows, and a trailing gate-state line: either `All exit criteria MET — the close gate is open.` or `N unresolved (pending or NOT_MET) — the close gate refuses status→COMPLETED until adjudicated (human override only via web).`
158
+
159
+ ```bash
160
+ lumo milestone criteria list "Q3 Launch"
118
161
  ```
119
162
 
163
+ ### `lumo milestone criteria verdict <identifier> <criterion> --met|--unmet` — adjudicate one criterion
164
+
165
+ Records the adjudication (裁定) on one exit criterion — verdict, who, when, and the adjudicating session (LUM-640 steward provenance) are stamped server-side; re-adjudication overwrites in place (latest verdict wins). `<criterion>` accepts the full id or a **≥6-char unique id prefix** from `criteria list` (ambiguous/unknown selectors list candidates and exit 1).
166
+
167
+ | Flag | Type | Notes |
168
+ | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
169
+ | `--met` / `--unmet` | boolean | Exactly one required. |
170
+ | `--note <text>` | string | Why this verdict (recommended). |
171
+ | `--evidence <pointer>` | string | **Required for `--met` on an `EXTERNAL_FACT` criterion** (422 otherwise) — the fact it was read from. |
172
+ | `--project <ref>` | string | Required when identifier is a name and workspace has >1 project. |
173
+
174
+ **Grounding gate (LUM-602/605 reuse):** `--met` on `EXTERNAL_FACT` without `--evidence` → **422** (re-ground or honestly re-declare the criterion `AGENT_CONSTRUCTED_STATE`); a `PENDING_OUTCOME` criterion can **never** be adjudicated MET (its truth lands post-delivery — use the outcome well). `--unmet` never needs evidence.
175
+
176
+ ```bash
177
+ lumo milestone criteria verdict "Q3 Launch" cmexit0001 --met --evidence "prisma migrate status @ prod, 2026-07-02" --note "all LUM-64x applied"
178
+ lumo milestone criteria verdict "Q3 Launch" cmexit0002 --unmet --note "read model not wired yet"
179
+ ```
180
+
181
+ ### When to suggest `milestone criteria`
182
+
183
+ - A steward session planning a milestone ("what does done mean for this milestone", "draft exit criteria") → `criteria set`.
184
+ - Before recommending `milestone update --status completed`, or when it just bounced with 409 → `criteria list` to see what holds the gate, then `verdict` per criterion.
185
+ - User asks "can we close this milestone / what's blocking the close" → `criteria list` (the gate-state line answers it).
186
+
120
187
  ### `lumo milestone delete <identifier>` — delete a milestone
121
188
 
122
189
  Requires `--yes`. No interactive prompt — CLI is agent-friendly. Tasks under the milestone keep their data; their `milestoneId` is cleared.
@@ -184,6 +251,29 @@ Q3 Launch: 1 removed, 1 skipped
184
251
  - `remove` only clears the binding for tasks actually in the named milestone, so it's safe to pass a broad list — anything not in it is reported as skipped, not clobbered.
185
252
  - For one-at-a-time sprint binding see `lumo sprint add / remove` (sprint batch is not yet supported).
186
253
 
254
+ ### `lumo milestone triage <identifier> <task...> --claimable | --not-claimable` — steward batch agent-claimable triage (LUM-646)
255
+
256
+ The **steward's batch annotation** for the F4/自治边界 loop. Marks **one or more** in-milestone tasks agent-claimable (or not) in one call — the "可领范围" leg of the autonomy-boundary triad (契约 F1 / 预算 F2 / 可领范围 F4). Exactly one direction is required: `--claimable` or `--not-claimable` (mutually exclusive; the CLI errors before any network call if both or neither is given).
257
+
258
+ Mirrors `milestone add/remove`: dedupes refs, and a task **not in this milestone is skipped** (triage is milestone-scoped, never touches tasks elsewhere). `<identifier>` accepts a name or UUID; each `<task>` accepts `LUM-N` or a task UUID. `--project <ref>` is required when the identifier is a name and the workspace has >1 project. Best-effort — partial failures do not roll back.
259
+
260
+ Setting the marker is a **governance** action, so a STEWARD session (`lumo session attach --steward <milestone>`) passes the server-side binding guard. `claimable` is **fail-closed**: a task is not auto-claimable until triaged. Once tasks are triaged, an agent runs `lumo next --claimable` to get only claimable ∩ unblocked (F3) ∩ in-budget (F2) work. A single task can also be toggled with `lumo task update <id> --claimable | --not-claimable`.
261
+
262
+ ```bash
263
+ lumo milestone triage "Agent-Native Milestones" LUM-1 LUM-2 --claimable
264
+ lumo milestone triage "Agent-Native Milestones" LUM-9 --not-claimable
265
+ ```
266
+
267
+ Output — `✓` marked, `-` skipped (not in milestone), `✗` failed:
268
+
269
+ ```
270
+ Agent-Native Milestones: 1 marked claimable, 1 skipped
271
+ ✓ LUM-1
272
+ - LUM-9 not in this milestone
273
+ ```
274
+
275
+ **When to suggest `milestone triage`**: the user (typically in a steward/governance session) wants to declare which of a milestone's tasks are safe for an agent to autonomously pick up ("mark these claimable", "open these up for agents", "triage the milestone for auto-dispatch"). For a single task, `task update --claimable` is equally fine.
276
+
187
277
  ### `lumo milestone summary <identifier> [--retry]` — fetch AI-generated milestone retro
188
278
 
189
279
  Prints the AI-generated retrospective summary for a milestone (mirrors `sprint summary`). `<identifier>` accepts a milestone name or UUID; `--project <ref>` is required when the identifier is a name and the workspace has more than one project. When no summary exists yet the command prints `(no summary generated yet)`.
@@ -132,6 +132,7 @@ lumo session attach --steward cmxyz123... # by milestone id
132
132
  - **Resolution:** exact id → case-insensitive name/slug across the workspace's active milestones. An **ambiguous** name is refused with 400 listing the candidates (retry with the id); no match is 404.
133
133
  - **Lifetime lock, same semantics as the task form:** re-attach to the **same** milestone = idempotent no-op; attach a **task** after a steward bind → 409; attach a **different milestone** → 409; a task-bound session running `--steward` → 409. No `--force`, no detach — a different binding needs a new Claude Code session.
134
134
  - **Multiple stewards per milestone are allowed** (acceptance is multi-round by nature); sessions don't conflict.
135
+ - **Prior-milestone learnings injected at attach (LUM-647):** the attach prints a `## Prior-milestone learnings (this project)` section — the **MILESTONE-scope** memories distilled from this project's earlier milestone-close retros (a cross-milestone learning unit), wrapped in the `<untrusted-team-memory>` boundary. Empty when the project has no prior distilled learnings; the current milestone's own rows are excluded. A resumed steward session re-gets it via the session-start hook. These memories are written automatically when a milestone closes and its retro is generated — there is no manual command to author them.
135
136
 
136
137
  **Allowed (governance — pass through unchanged, with the session recorded as provenance):** `task create` / `task update`, `task criteria set` (incl. `--human`), `task deps confirm/dismiss`, `verdict --fail` (the send-back rows carry `sourceSessionId`), `milestone add/remove`, and **all read operations**.
137
138
 
@@ -59,6 +59,8 @@ Pure flag-driven update. Provide at least one of:
59
59
  | `-a, --assignee <ref>` | string | `me`, an email, or a member name. `--assignee ""` clears the field. |
60
60
  | `--milestone <ref>` | string | Milestone name (case-insensitive) within the task's project. `--milestone ""` unbinds. |
61
61
  | `--sprint <ref>` | string | Sprint number or UUID to bind the task to. `--sprint ""` clears the current sprint binding (idempotent when already unbound). |
62
+ | `--claimable` | boolean | LUM-646: mark the task agent-claimable (fail-closed default is false). Surfaces the task in `lumo next --claimable`. Mutually exclusive with `--not-claimable`. |
63
+ | `--not-claimable` | boolean | LUM-646: mark the task NOT agent-claimable. Mutually exclusive with `--claimable`. |
62
64
  | `--tag <name>` | string (repeatable) | **Bulk replace** the tag set by name. Creates tag if missing. Max 20. Mutually exclusive with `--add-tag*` / `--remove-tag*`. |
63
65
  | `--tag-id <cuid>` | string (repeatable) | **Bulk replace** the tag set by id. Max 20. Mutually exclusive with `--add-tag*` / `--remove-tag*`. |
64
66
  | `--add-tag <name>` | string (repeatable) | Attach tag by name (find-or-create). Max 20. |
@@ -170,27 +172,35 @@ Filtering is currently client-side — the server returns the full "my tasks" se
170
172
  - The user asks "what am I working on", "what tasks do I have", "list my tasks", "show me my queue".
171
173
  - Before suggesting a status change ("mark something as done"), if no task ID is in context — run `task list` first to surface candidates.
172
174
 
173
- ### `lumo next [--count <N>]` — recommend the next task to work on
175
+ ### `lumo next [--count <N>] [--claimable]` — recommend the next task to work on
174
176
 
175
177
  Ranks the tasks assigned to you and prints the top N (default 3), each with a
176
178
  one-line reason. Read-only — it does **not** bind or load context. Pick one from
177
179
  the list, then run `lumo session attach <LUM-N>` + `lumo task context <LUM-N>`.
178
180
 
179
181
  Ranking is lexicographic: **priority** (URGENT→LOW) first, then **active-sprint
180
- membership**, then **due date** (earlier first), then in-flight status
181
- (IN_PROGRESS / IN_REVIEW ahead of TODO). DONE tasks are excluded. The active
182
- sprint lookup is best-effort if it fails the command still recommends, just
183
- without the sprint boost.
184
-
185
- | Flag | Type | Notes |
186
- | ----------------- | ------- | ----------------------------------------------------------------------- |
187
- | `-n, --count <N>` | integer | How many tasks to recommend. Defaults to 3. Must be a positive integer. |
182
+ membership**, then **dependency topology** (LUM-645: tasks with an open
183
+ CONFIRMED blocker sort after workable ones; among workable peers the task that
184
+ directly unblocks the most open tasks ranks first), then **due date** (earlier
185
+ first), then in-flight status (IN_PROGRESS / IN_REVIEW ahead of TODO). DONE
186
+ tasks are excluded. The active sprint lookup is best-effort — if it fails the
187
+ command still recommends, just without the sprint boost. The topology counts
188
+ come from the server (`/api/tasks/me`); against an older server without them
189
+ the order is simply the pre-topology ranking.
190
+
191
+ | Flag | Type | Notes |
192
+ | ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
193
+ | `-n, --count <N>` | integer | How many tasks to recommend. Defaults to 3. Must be a positive integer. |
194
+ | `--claimable` | boolean | LUM-646 (F4/自治边界): restrict to the autonomy boundary — recommend **only** tasks that are agent-claimable (steward-triaged) **and** unblocked (no open CONFIRMED blocker, F3) **and** within milestone budget (not measured over the LUM-644 token budget, F2). Off by default (unchanged ranking). |
188
195
 
189
196
  ```bash
190
197
  lumo next
191
198
  lumo next --count 1
199
+ lumo next --claimable # autonomy-scoped: only claimable ∩ unblocked ∩ in-budget
192
200
  ```
193
201
 
202
+ **`--claimable` semantics (LUM-646):** the three-part gate is a hard filter, not just a re-ranking (default `next` still soft-deprioritizes blocked tasks per LUM-645). `claimable` is **fail-closed** — a task is not claimable until a steward (`lumo milestone triage`) or `lumo task update --claimable` marks it, so the default field value excludes it. Budget is **fail-open on the unknown** — only a _measured_ overrun excludes; no budget set / unmeasured burn never gates. When the gate empties the list, the output states the honest breakdown (e.g. `No claimable, unblocked, in-budget tasks right now. (excluded: 3 not triaged claimable, 1 blocked by open deps)`). Against a server that predates F4 (no `claimable` field), `--claimable` prints a one-line notice and **degrades to the standard ranking** rather than returning nothing.
203
+
194
204
  Output:
195
205
 
196
206
  ```
@@ -199,9 +209,9 @@ Top 3 recommended tasks (of 12 open):
199
209
  1. LUM-42 IN_PROGRESS URGENT Fix Slack OAuth redirect
200
210
  ↳ URGENT · active sprint · due 2026-06-03 (overdue) · in progress
201
211
  2. LUM-48 TODO HIGH Investigate slow query
202
- ↳ HIGH · active sprint
212
+ ↳ HIGH · active sprint · unblocks 2 tasks
203
213
  3. LUM-12 TODO MEDIUM Add rate limiting
204
- ↳ MEDIUM · due 2026-06-10
214
+ ↳ MEDIUM · blocked by 1 open dep · due 2026-06-10
205
215
 
206
216
  Next: lumo session attach LUM-42 && lumo task context LUM-42
207
217
  ```
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatExitCriteriaRows = formatExitCriteriaRows;
4
+ exports.milestoneCriteriaList = milestoneCriteriaList;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const resolve_1 = require("../lib/resolve");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ /**
10
+ * Render exit-criterion rows for stdout. One line per criterion —
11
+ * `<id> [MET|NOT_MET|PENDING] SOURCE ⟨REFERENT⟩ statement` — plus an
12
+ * indented verdict detail line when adjudicated. Shared by `criteria list`,
13
+ * the `criteria set` echo and `criteria verdict`.
14
+ */
15
+ function formatExitCriteriaRows(criteria) {
16
+ const lines = [];
17
+ for (const c of criteria) {
18
+ const verdict = c.verdict ?? 'PENDING';
19
+ lines.push(`${c.id} [${verdict}] ${c.source} ⟨${c.referentKind}⟩ ${(0, sanitize_1.sanitizeField)(c.statement)}`);
20
+ if (c.verdict) {
21
+ const parts = [];
22
+ if (c.verdictAt)
23
+ parts.push(`at ${c.verdictAt.slice(0, 10)}`);
24
+ if (c.verdictEvidence)
25
+ parts.push(`evidence: ${(0, sanitize_1.sanitizeField)(c.verdictEvidence)}`);
26
+ if (c.verdictNote)
27
+ parts.push(`note: ${(0, sanitize_1.sanitizeField)(c.verdictNote)}`);
28
+ if (parts.length > 0)
29
+ lines.push(` ↳ ${parts.join(' · ')}`);
30
+ }
31
+ }
32
+ return lines.length > 0 ? lines.join('\n') + '\n' : '';
33
+ }
34
+ /** `lumo milestone criteria list <milestone>` — print the exit contract. */
35
+ async function milestoneCriteriaList(identifier, opts) {
36
+ const creds = (0, config_1.readCredentials)();
37
+ if (!creds) {
38
+ console.error('Error: not logged in. Run `lumo auth login` first.');
39
+ return 1;
40
+ }
41
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
42
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
43
+ let resolved;
44
+ try {
45
+ resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
46
+ }
47
+ catch (err) {
48
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
49
+ return 1;
50
+ }
51
+ let res;
52
+ try {
53
+ res = await fetch(`${base}/api/milestones/${resolved.id}/criteria`, {
54
+ headers: { Authorization: `Bearer ${creds.token}` },
55
+ });
56
+ }
57
+ catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
60
+ return 1;
61
+ }
62
+ if (res.status === 401) {
63
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
64
+ return 1;
65
+ }
66
+ if (!res.ok) {
67
+ const body = (await res.json().catch(() => null));
68
+ console.error(`Error: exit-criteria list failed (HTTP ${res.status})${body?.error ? `: ${(0, sanitize_1.sanitizeField)(body.error)}` : ''}`);
69
+ return 1;
70
+ }
71
+ const data = (await res.json());
72
+ if (data.criteria.length === 0) {
73
+ process.stdout.write(`No exit criteria on this milestone — draft them and submit with lumo milestone criteria set ${identifier} --file <criteria.json>\n`);
74
+ return;
75
+ }
76
+ process.stdout.write(formatExitCriteriaRows(data.criteria));
77
+ process.stdout.write(data.unresolvedCount === 0
78
+ ? 'All exit criteria MET — the close gate is open.\n'
79
+ : `${data.unresolvedCount} unresolved (pending or NOT_MET) — the close gate refuses status→COMPLETED until adjudicated (human override only via web).\n`);
80
+ }
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseExitCriteriaJson = parseExitCriteriaJson;
4
+ exports.milestoneCriteriaSet = milestoneCriteriaSet;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const resolve_1 = require("../lib/resolve");
8
+ const doc_input_1 = require("../lib/doc-input");
9
+ const path_guard_1 = require("../lib/path-guard");
10
+ const sanitize_1 = require("../lib/sanitize");
11
+ const milestone_criteria_list_1 = require("./milestone-criteria-list");
12
+ const REFERENT_KINDS = [
13
+ 'EXTERNAL_FACT',
14
+ 'AGENT_CONSTRUCTED_STATE',
15
+ 'PENDING_OUTCOME',
16
+ ];
17
+ /**
18
+ * Client-side shape gate: fail fast on obviously malformed JSON before the
19
+ * round-trip. Full validation stays server-side. The file is a JSON array;
20
+ * one object per criterion: { statement, referentKind } — no verifierType /
21
+ * checkpointer (milestone exit criteria are adjudicated, not machine-run).
22
+ */
23
+ function parseExitCriteriaJson(raw) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ }
28
+ catch (err) {
29
+ return {
30
+ ok: false,
31
+ error: `file is not valid JSON (${err instanceof Error ? err.message : String(err)})`,
32
+ };
33
+ }
34
+ if (!Array.isArray(parsed)) {
35
+ return { ok: false, error: 'file must be a JSON array of criteria' };
36
+ }
37
+ if (parsed.length === 0) {
38
+ return { ok: false, error: 'file contains an empty array' };
39
+ }
40
+ for (const [i, item] of parsed.entries()) {
41
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
42
+ return { ok: false, error: `item ${i} is not an object` };
43
+ }
44
+ const o = item;
45
+ if (typeof o.statement !== 'string' || o.statement.trim() === '') {
46
+ return { ok: false, error: `item ${i} is missing "statement"` };
47
+ }
48
+ if (typeof o.referentKind !== 'string' ||
49
+ !REFERENT_KINDS.includes(o.referentKind)) {
50
+ return {
51
+ ok: false,
52
+ error: `item ${i} needs "referentKind" — one of ${REFERENT_KINDS.join(' | ')}`,
53
+ };
54
+ }
55
+ }
56
+ return { ok: true, items: parsed };
57
+ }
58
+ /**
59
+ * `lumo milestone criteria set <milestone> --file <criteria.json> [--human]`
60
+ * — submit the whole exit contract (LUM-643, full-group replace; adjudicated
61
+ * rows are tombstoned server-side so their verdict trail survives). Default
62
+ * source is AGENT_DRAFT (the steward draft); `--human` records a transcribed
63
+ * HUMAN_EDIT revision. Locked with 409 once the milestone is COMPLETED.
64
+ */
65
+ async function milestoneCriteriaSet(identifier, opts) {
66
+ if (!opts.file) {
67
+ console.error('Error: --file <criteria.json> is required. The file is a JSON array of { statement, referentKind }.');
68
+ return 1;
69
+ }
70
+ const pathVerdict = (0, path_guard_1.checkArtifactFilePath)(opts.file);
71
+ if (!pathVerdict.ok) {
72
+ if (pathVerdict.reason === 'unreadable') {
73
+ console.error(`Error: ${(0, doc_input_1.unreadableFileMessage)(opts.file)}`);
74
+ }
75
+ else {
76
+ console.error(`Error: refusing to read ${opts.file} — ${pathVerdict.detail}. ` +
77
+ `criteria --file must be a non-sensitive path inside the project directory.`);
78
+ }
79
+ return 1;
80
+ }
81
+ let raw;
82
+ try {
83
+ raw = await (0, doc_input_1.readFileUtf8)(pathVerdict.resolved);
84
+ }
85
+ catch {
86
+ console.error(`Error: ${(0, doc_input_1.unreadableFileMessage)(opts.file)}`);
87
+ return 1;
88
+ }
89
+ const parsed = parseExitCriteriaJson(raw);
90
+ if (!parsed.ok) {
91
+ console.error(`Error: ${parsed.error}`);
92
+ return 1;
93
+ }
94
+ const creds = (0, config_1.readCredentials)();
95
+ if (!creds) {
96
+ console.error('Error: not logged in. Run `lumo auth login` first.');
97
+ return 1;
98
+ }
99
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
100
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
101
+ let resolved;
102
+ try {
103
+ resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
104
+ }
105
+ catch (err) {
106
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
107
+ return 1;
108
+ }
109
+ const headers = {
110
+ Authorization: `Bearer ${creds.token}`,
111
+ 'Content-Type': 'application/json',
112
+ };
113
+ // Steward/session provenance (LUM-640) — recorded on each drafted row.
114
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
115
+ if (sessionId)
116
+ headers['X-Lumo-Session-Id'] = sessionId;
117
+ let res;
118
+ try {
119
+ res = await fetch(`${base}/api/milestones/${resolved.id}/criteria`, {
120
+ method: 'PUT',
121
+ headers,
122
+ body: JSON.stringify({
123
+ source: opts.human ? 'HUMAN_EDIT' : 'AGENT_DRAFT',
124
+ criteria: parsed.items,
125
+ }),
126
+ });
127
+ }
128
+ catch (err) {
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
131
+ return 1;
132
+ }
133
+ if (res.status === 401) {
134
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
135
+ return 1;
136
+ }
137
+ if (!res.ok) {
138
+ const body = (await res.json().catch(() => null));
139
+ console.error(`Error: exit-criteria set failed (HTTP ${res.status})${body?.error ? `: ${(0, sanitize_1.sanitizeField)(body.error)}` : ''}`);
140
+ return 1;
141
+ }
142
+ const data = (await res.json());
143
+ process.stdout.write(`Exit contract set (${data.criteria.length} criteria) — adjudicate with lumo milestone criteria verdict\n`);
144
+ process.stdout.write((0, milestone_criteria_list_1.formatExitCriteriaRows)(data.criteria));
145
+ }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveCriterionSelector = resolveCriterionSelector;
4
+ exports.milestoneCriteriaVerdict = milestoneCriteriaVerdict;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const resolve_1 = require("../lib/resolve");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ const milestone_criteria_list_1 = require("./milestone-criteria-list");
10
+ /**
11
+ * Resolve a criterion selector (full id or a ≥6-char unique id prefix)
12
+ * against the milestone's active contract. Ambiguous / unknown selectors
13
+ * list candidates and fail — mirrors `task deps` edge selection.
14
+ */
15
+ function resolveCriterionSelector(selector, criteria) {
16
+ const exact = criteria.find(c => c.id === selector);
17
+ if (exact)
18
+ return { ok: true, id: exact.id };
19
+ if (selector.length < 6) {
20
+ return {
21
+ ok: false,
22
+ error: `criterion selector "${selector}" is too short — pass the id (or a prefix of at least 6 chars) from \`lumo milestone criteria list\``,
23
+ };
24
+ }
25
+ const hits = criteria.filter(c => c.id.startsWith(selector));
26
+ if (hits.length === 1)
27
+ return { ok: true, id: hits[0].id };
28
+ if (hits.length === 0) {
29
+ return {
30
+ ok: false,
31
+ error: `no exit criterion matches "${selector}" — see \`lumo milestone criteria list\``,
32
+ };
33
+ }
34
+ const candidates = hits
35
+ .map(h => ` ${h.id} ${h.statement.slice(0, 60)}`)
36
+ .join('\n');
37
+ return {
38
+ ok: false,
39
+ error: `ambiguous selector "${selector}" matches ${hits.length} criteria:\n${candidates}`,
40
+ };
41
+ }
42
+ /**
43
+ * `lumo milestone criteria verdict <milestone> <criterion> --met|--unmet`
44
+ * — adjudicate (裁定) one exit criterion (LUM-643). The steward session
45
+ * (LUM-640) is the intended adjudicating subject; who/when/session are
46
+ * stamped server-side. Grounding gate: `--met` on an EXTERNAL_FACT criterion
47
+ * requires `--evidence`, and a PENDING_OUTCOME criterion can never be MET —
48
+ * both rejected with 422 server-side.
49
+ */
50
+ async function milestoneCriteriaVerdict(identifier, criterionSelector, opts) {
51
+ if (opts.met === opts.unmet) {
52
+ console.error('Error: pass exactly one of --met or --unmet');
53
+ return 1;
54
+ }
55
+ const creds = (0, config_1.readCredentials)();
56
+ if (!creds) {
57
+ console.error('Error: not logged in. Run `lumo auth login` first.');
58
+ return 1;
59
+ }
60
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
61
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
62
+ let resolved;
63
+ try {
64
+ resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
65
+ }
66
+ catch (err) {
67
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
68
+ return 1;
69
+ }
70
+ // Fetch the active contract to resolve the criterion selector client-side.
71
+ let listRes;
72
+ try {
73
+ listRes = await fetch(`${base}/api/milestones/${resolved.id}/criteria`, {
74
+ headers: { Authorization: `Bearer ${creds.token}` },
75
+ });
76
+ }
77
+ catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
80
+ return 1;
81
+ }
82
+ if (!listRes.ok) {
83
+ console.error(`Error: exit-criteria list failed (HTTP ${listRes.status})`);
84
+ return 1;
85
+ }
86
+ const { criteria } = (await listRes.json());
87
+ const selector = resolveCriterionSelector(criterionSelector, criteria);
88
+ if (!selector.ok) {
89
+ console.error(`Error: ${selector.error}`);
90
+ return 1;
91
+ }
92
+ const headers = {
93
+ Authorization: `Bearer ${creds.token}`,
94
+ 'Content-Type': 'application/json',
95
+ };
96
+ // Adjudicating-session provenance (LUM-640) — stamped on the verdict.
97
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
98
+ if (sessionId)
99
+ headers['X-Lumo-Session-Id'] = sessionId;
100
+ let res;
101
+ try {
102
+ res = await fetch(`${base}/api/milestones/${resolved.id}/criteria/${selector.id}/verdict`, {
103
+ method: 'POST',
104
+ headers,
105
+ body: JSON.stringify({
106
+ verdict: opts.met ? 'MET' : 'NOT_MET',
107
+ ...(opts.note ? { note: opts.note } : {}),
108
+ ...(opts.evidence ? { evidence: opts.evidence } : {}),
109
+ }),
110
+ });
111
+ }
112
+ catch (err) {
113
+ const msg = err instanceof Error ? err.message : String(err);
114
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
115
+ return 1;
116
+ }
117
+ if (res.status === 401) {
118
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
119
+ return 1;
120
+ }
121
+ if (!res.ok) {
122
+ const body = (await res.json().catch(() => null));
123
+ console.error(`Error: verdict failed (HTTP ${res.status})${body?.error ? `: ${(0, sanitize_1.sanitizeField)(body.error)}` : ''}`);
124
+ return 1;
125
+ }
126
+ const data = (await res.json());
127
+ process.stdout.write(`Adjudicated ${data.criterion.verdict} on exit criterion:\n`);
128
+ process.stdout.write((0, milestone_criteria_list_1.formatExitCriteriaRows)([data.criterion]));
129
+ }
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.milestoneTriage = milestoneTriage;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const resolve_1 = require("../lib/resolve");
7
+ const milestone_batch_1 = require("../lib/milestone-batch");
8
+ /** GET the milestone's canonical name; needed only when a UUID was passed. */
9
+ async function resolveMilestoneName(base, token, id, knownName) {
10
+ if (knownName)
11
+ return knownName;
12
+ const res = await fetch(`${base}/api/milestones/${id}`, {
13
+ headers: { Authorization: `Bearer ${token}` },
14
+ });
15
+ if (!res.ok) {
16
+ throw new Error(`milestone ${id} not found (HTTP ${res.status})`);
17
+ }
18
+ const { milestone } = (await res.json());
19
+ return milestone.name;
20
+ }
21
+ /**
22
+ * `lumo milestone triage <milestone> <task...> --claimable|--not-claimable` —
23
+ * the steward's batch agent-claimable annotation (LUM-646, F4/自治边界).
24
+ *
25
+ * Mirrors `milestone add/remove`: dedupe refs, partition against the
26
+ * milestone's task set, PATCH each in-milestone task with the chosen claimable
27
+ * direction, and skip anything not in the milestone (triage is milestone-
28
+ * scoped). Best-effort — partial failures don't roll back. Setting the marker
29
+ * is a governance action, so a STEWARD session passes the server-side binding
30
+ * guard (task update is `allow-governance`).
31
+ */
32
+ async function milestoneTriage(identifier, tasks, opts) {
33
+ // Exactly one direction must be chosen.
34
+ if (opts.claimable && opts.notClaimable) {
35
+ console.error('Error: --claimable and --not-claimable are mutually exclusive.');
36
+ return 1;
37
+ }
38
+ if (!opts.claimable && !opts.notClaimable) {
39
+ console.error('Error: choose a direction — pass --claimable or --not-claimable.');
40
+ return 1;
41
+ }
42
+ const claimable = Boolean(opts.claimable);
43
+ const refs = (0, milestone_batch_1.dedupeTaskRefs)(tasks);
44
+ if (refs.length === 0) {
45
+ console.error('Error: provide at least one task (e.g. `LUM-1 LUM-2`).');
46
+ return 1;
47
+ }
48
+ const creds = (0, config_1.readCredentials)();
49
+ if (!creds) {
50
+ console.error('Error: not logged in. Run `lumo auth login` first.');
51
+ return 1;
52
+ }
53
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
54
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
55
+ let milestoneId;
56
+ let milestoneName;
57
+ let milestoneTasks;
58
+ try {
59
+ const resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
60
+ milestoneId = resolved.id;
61
+ milestoneName = await resolveMilestoneName(base, creds.token, resolved.id, resolved.name);
62
+ const tasksRes = await fetch(`${base}/api/milestones/${milestoneId}/tasks`, { headers: { Authorization: `Bearer ${creds.token}` } });
63
+ if (!tasksRes.ok) {
64
+ throw new Error(`milestone tasks fetch failed (HTTP ${tasksRes.status})`);
65
+ }
66
+ const body = (await tasksRes.json());
67
+ milestoneTasks = body.tasks;
68
+ }
69
+ catch (err) {
70
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
71
+ return 1;
72
+ }
73
+ const { present, absent } = (0, milestone_batch_1.partitionMembership)(refs, milestoneTasks);
74
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
75
+ const outcomes = [];
76
+ for (const ref of present) {
77
+ try {
78
+ const headers = {
79
+ Authorization: `Bearer ${creds.token}`,
80
+ 'Content-Type': 'application/json',
81
+ };
82
+ if (sessionId)
83
+ headers['X-Lumo-Session-Id'] = sessionId;
84
+ const res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(ref)}`, {
85
+ method: 'PATCH',
86
+ headers,
87
+ body: JSON.stringify({ claimable }),
88
+ });
89
+ if (res.ok) {
90
+ outcomes.push({ task: ref, status: 'ok' });
91
+ }
92
+ else {
93
+ let msg = `triage failed (HTTP ${res.status})`;
94
+ try {
95
+ const errBody = (await res.json());
96
+ if (errBody.error)
97
+ msg = errBody.error;
98
+ }
99
+ catch {
100
+ // non-JSON body; keep the status-only message
101
+ }
102
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
103
+ }
104
+ }
105
+ catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ outcomes.push({ task: ref, status: 'fail', detail: msg });
108
+ }
109
+ }
110
+ for (const ref of absent) {
111
+ outcomes.push({
112
+ task: ref,
113
+ status: 'skip',
114
+ detail: 'not in this milestone',
115
+ });
116
+ }
117
+ process.stdout.write((0, milestone_batch_1.formatBatchSummary)({
118
+ milestoneName,
119
+ verb: claimable ? 'marked claimable' : 'marked not-claimable',
120
+ outcomes,
121
+ }) + '\n');
122
+ return outcomes.some(o => o.status === 'fail') ? 1 : undefined;
123
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeMilestoneStatus = normalizeMilestoneStatus;
4
+ exports.normalizeTokenBudget = normalizeTokenBudget;
4
5
  exports.buildMilestoneUpdatePayload = buildMilestoneUpdatePayload;
5
6
  exports.formatMilestoneUpdateSummary = formatMilestoneUpdateSummary;
6
7
  exports.milestoneUpdate = milestoneUpdate;
@@ -22,6 +23,21 @@ function normalizeMilestoneStatus(value) {
22
23
  ? upper
23
24
  : null;
24
25
  }
26
+ /**
27
+ * LUM-644: '' clears the budget (→ null, the nullable-flag convention);
28
+ * otherwise the value must be a positive integer within Postgres INT range.
29
+ * Invalid input → undefined (caller prints the error and exits 1).
30
+ */
31
+ function normalizeTokenBudget(value) {
32
+ if (value === '')
33
+ return null;
34
+ if (!/^\d+$/.test(value))
35
+ return undefined;
36
+ const n = Number(value);
37
+ if (!Number.isSafeInteger(n) || n < 1 || n > 2_147_483_647)
38
+ return undefined;
39
+ return n;
40
+ }
25
41
  function buildMilestoneUpdatePayload(opts) {
26
42
  const payload = {};
27
43
  const flagsGiven = [];
@@ -45,6 +61,12 @@ function buildMilestoneUpdatePayload(opts) {
45
61
  payload.targetDate = opts.target === '' ? null : opts.target;
46
62
  flagsGiven.push('--target');
47
63
  }
64
+ if (opts.tokenBudget !== undefined) {
65
+ // The caller validated eagerly (normalizeTokenBudget); '' means clear.
66
+ payload.tokenBudget =
67
+ opts.tokenBudget === '' ? null : Number(opts.tokenBudget);
68
+ flagsGiven.push('--token-budget');
69
+ }
48
70
  return { payload, flagsGiven };
49
71
  }
50
72
  function fmtDate(v) {
@@ -72,6 +94,11 @@ function formatMilestoneUpdateSummary(before, after) {
72
94
  if (after.targetDate !== undefined) {
73
95
  changes.push(`target → ${fmtDate(after.targetDate)}`);
74
96
  }
97
+ if (after.tokenBudget !== undefined) {
98
+ changes.push(after.tokenBudget === null
99
+ ? 'token budget → ∅'
100
+ : `token budget → ${after.tokenBudget.toLocaleString('en-US')}`);
101
+ }
75
102
  return `Updated milestone "${beforeName}": ${changes.join(', ')}`;
76
103
  }
77
104
  async function milestoneUpdate(identifier, opts) {
@@ -85,12 +112,18 @@ async function milestoneUpdate(identifier, opts) {
85
112
  }
86
113
  normalizedStatus = n;
87
114
  }
115
+ // Validate token budget eagerly (LUM-644): positive integer, or '' to clear.
116
+ if (opts.tokenBudget !== undefined &&
117
+ normalizeTokenBudget(opts.tokenBudget) === undefined) {
118
+ console.error(`Error: invalid token budget "${opts.tokenBudget}". Provide a positive integer (e.g. 5000000), or "" to clear.`);
119
+ return 1;
120
+ }
88
121
  const { payload, flagsGiven } = buildMilestoneUpdatePayload({
89
122
  ...opts,
90
123
  ...(normalizedStatus !== undefined && { status: normalizedStatus }),
91
124
  });
92
125
  if (flagsGiven.length === 0) {
93
- console.error('Error: provide at least one field to update (--name, --description, --status, --start, --target)');
126
+ console.error('Error: provide at least one field to update (--name, --description, --status, --start, --target, --token-budget)');
94
127
  return 1;
95
128
  }
96
129
  const creds = (0, config_1.readCredentials)();
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.nextCommand = nextCommand;
4
+ exports.formatClaimableOutput = formatClaimableOutput;
4
5
  exports.formatNextOutput = formatNextOutput;
5
6
  const config_1 = require("../lib/config");
6
7
  const api_1 = require("../lib/api");
7
8
  const sanitize_1 = require("../lib/sanitize");
8
9
  const rank_tasks_1 = require("../lib/rank-tasks");
10
+ const claimable_filter_1 = require("../lib/claimable-filter");
9
11
  /**
10
12
  * `lumo next [-n, --count <N>]` — recommend the next task(s) to work on.
11
13
  *
@@ -45,10 +47,14 @@ async function nextCommand(opts) {
45
47
  const msg = err instanceof Error ? err.message : String(err);
46
48
  console.error(`Warning: could not load active sprints (${msg}); ranking without sprint boost.`);
47
49
  }
48
- // 2. My tasks — fatal on failure.
50
+ // 2. My tasks — fatal on failure. `?claimable=1` asks the server to also
51
+ // compute the per-milestone F2 budget signal (only needed for the gate).
52
+ const tasksUrl = opts.claimable
53
+ ? `${base}/api/tasks/me?claimable=1`
54
+ : `${base}/api/tasks/me`;
49
55
  let res;
50
56
  try {
51
- res = await fetch(`${base}/api/tasks/me`, { headers: authHeaders });
57
+ res = await fetch(tasksUrl, { headers: authHeaders });
52
58
  }
53
59
  catch (err) {
54
60
  const msg = err instanceof Error ? err.message : String(err);
@@ -65,15 +71,46 @@ async function nextCommand(opts) {
65
71
  }
66
72
  const data = (await res.json());
67
73
  const open = data.tasks.filter(t => t.status !== 'DONE');
74
+ // LUM-646: the autonomy gate. Only when --claimable is passed; otherwise the
75
+ // standard LUM-645 ranking is untouched.
76
+ if (opts.claimable) {
77
+ const gate = (0, claimable_filter_1.filterClaimable)(open);
78
+ if (!gate.supported) {
79
+ // Older server predating F4 — degrade to the standard ranking with a
80
+ // one-line notice rather than silently returning nothing.
81
+ console.error('Note: `--claimable` needs an updated Lumo server; showing the standard ranking instead.');
82
+ }
83
+ else {
84
+ const ranked = (0, rank_tasks_1.rankTasks)(gate.kept, activeSprintIds, new Date()).slice(0, count);
85
+ process.stdout.write(formatClaimableOutput(ranked, gate.kept.length, gate.excluded) + '\n');
86
+ return;
87
+ }
88
+ }
68
89
  const ranked = (0, rank_tasks_1.rankTasks)(open, activeSprintIds, new Date()).slice(0, count);
69
90
  process.stdout.write(formatNextOutput(ranked, open.length) + '\n');
70
91
  }
92
+ /**
93
+ * Render the `--claimable` recommendation block. Like `formatNextOutput` but
94
+ * scoped to the autonomy boundary: the header names the claimable count, and
95
+ * an empty result reports the honest exclusion breakdown (why nothing is
96
+ * claimable-and-workable) instead of the generic "no open tasks".
97
+ */
98
+ function formatClaimableOutput(ranked, totalClaimable, excluded) {
99
+ if (ranked.length === 0) {
100
+ const breakdown = (0, claimable_filter_1.formatClaimableExclusions)(excluded);
101
+ const base = 'No claimable, unblocked, in-budget tasks right now.';
102
+ return breakdown ? `${base} (excluded: ${breakdown})` : base;
103
+ }
104
+ return formatNextOutput(ranked, totalClaimable, 'claimable');
105
+ }
71
106
  /**
72
107
  * Render the ranked recommendation block. `ranked` is already sliced to the
73
108
  * requested count; `totalOpen` is the count of all non-DONE tasks (for the
74
109
  * "(of N open)" header). Pure — no I/O.
75
110
  */
76
- function formatNextOutput(ranked, totalOpen) {
111
+ function formatNextOutput(ranked, totalOpen,
112
+ /** Noun for the "(of N <noun>)" header — 'claimable' for the gated view. */
113
+ poolNoun = 'open') {
77
114
  const first = ranked[0];
78
115
  if (!first)
79
116
  return 'No open tasks assigned to you. 🎉';
@@ -84,7 +121,7 @@ function formatNextOutput(ranked, totalOpen) {
84
121
  };
85
122
  const plural = ranked.length === 1 ? '' : 's';
86
123
  const lines = [
87
- `Top ${ranked.length} recommended task${plural} (of ${totalOpen} open):`,
124
+ `Top ${ranked.length} recommended task${plural} (of ${totalOpen} ${poolNoun}):`,
88
125
  '',
89
126
  ];
90
127
  ranked.forEach((r, i) => {
@@ -238,4 +238,10 @@ async function stewardAttach(args) {
238
238
  console.log(' allowed: task create/update · criteria set (incl. --human) · deps confirm/dismiss · verdict --fail · milestone add/remove · all reads');
239
239
  console.log(' blocked: lumo verify (409, no round burned) · moving a task to in_progress assigned to yourself · attaching a task');
240
240
  console.log(' note: commits/PRs produced in this session are recorded as boundary crossings (human-reviewed on the web)');
241
+ // LUM-647: prior-milestone learnings distilled from this project's earlier
242
+ // milestone-close retros, injected so the steward carries them into planning.
243
+ if (body.stewardMemorySection && body.stewardMemorySection.trim() !== '') {
244
+ console.log('');
245
+ console.log((0, sanitize_1.sanitizeField)(body.stewardMemorySection));
246
+ }
241
247
  }
@@ -80,6 +80,12 @@ function buildUpdatePayload(opts) {
80
80
  payload.milestoneRef = opts.milestone === '' ? null : opts.milestone;
81
81
  flagsGiven.push('--milestone');
82
82
  }
83
+ // LUM-646: resolved agent-claimable marker (true/false). The caller collapses
84
+ // --claimable / --not-claimable into this single tri-state before building.
85
+ if (opts.claimable !== undefined) {
86
+ payload.claimable = opts.claimable;
87
+ flagsGiven.push(opts.claimable ? '--claimable' : '--not-claimable');
88
+ }
83
89
  return { payload, flagsGiven };
84
90
  }
85
91
  /**
@@ -111,6 +117,17 @@ async function taskUpdate(identifier, opts) {
111
117
  console.error('Error: --tag/--tag-id are mutually exclusive with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id');
112
118
  return 1;
113
119
  }
120
+ // LUM-646: collapse --claimable / --not-claimable into one tri-state
121
+ // (undefined = don't touch). The two flags cannot both be set.
122
+ if (opts.claimable && opts.notClaimable) {
123
+ console.error('Error: --claimable and --not-claimable are mutually exclusive');
124
+ return 1;
125
+ }
126
+ let claimable;
127
+ if (opts.claimable)
128
+ claimable = true;
129
+ else if (opts.notClaimable)
130
+ claimable = false;
114
131
  // Normalize status / priority before payload build.
115
132
  let status;
116
133
  if (opts.status !== undefined) {
@@ -134,6 +151,7 @@ async function taskUpdate(identifier, opts) {
134
151
  ...opts,
135
152
  ...(status !== undefined ? { status } : {}),
136
153
  ...(priority !== undefined ? { priority } : {}),
154
+ ...(claimable !== undefined ? { claimable } : {}),
137
155
  });
138
156
  // Tag fields count as "something to update" even when no other flags given
139
157
  const hasTagFields = hasBulk || hasIncremental;
@@ -372,7 +390,9 @@ async function taskUpdate(identifier, opts) {
372
390
  if (b.error)
373
391
  errMsg = b.error;
374
392
  }
375
- catch { /* ignore */ }
393
+ catch {
394
+ /* ignore */
395
+ }
376
396
  console.error(`Error: ${(0, sanitize_1.sanitizeField)(errMsg)}`);
377
397
  return 1;
378
398
  }
@@ -404,12 +424,16 @@ async function taskUpdate(identifier, opts) {
404
424
  if (b.error)
405
425
  errMsg = b.error;
406
426
  }
407
- catch { /* ignore */ }
427
+ catch {
428
+ /* ignore */
429
+ }
408
430
  console.error(`Error: ${(0, sanitize_1.sanitizeField)(errMsg)}`);
409
431
  return 1;
410
432
  }
411
433
  // resolvedSprintNumber is set from the resolveSprintId call above
412
- const newLabel = resolvedSprintNumber !== null ? `#${resolvedSprintNumber}` : action.newSprintId;
434
+ const newLabel = resolvedSprintNumber !== null
435
+ ? `#${resolvedSprintNumber}`
436
+ : action.newSprintId;
413
437
  process.stdout.write(`Sprint: - → ${newLabel}\n`);
414
438
  return;
415
439
  }
@@ -464,7 +488,9 @@ async function taskUpdate(identifier, opts) {
464
488
  }
465
489
  const oldLabel = oldNumber !== null ? `#${oldNumber}` : '-';
466
490
  // resolvedSprintNumber is set from the resolveSprintId call above
467
- const newLabel = resolvedSprintNumber !== null ? `#${resolvedSprintNumber}` : action.newSprintId;
491
+ const newLabel = resolvedSprintNumber !== null
492
+ ? `#${resolvedSprintNumber}`
493
+ : action.newSprintId;
468
494
  process.stdout.write(`Sprint: ${oldLabel} → ${newLabel}\n`);
469
495
  return;
470
496
  }
@@ -98,9 +98,13 @@ const milestone_archive_1 = require("./commands/milestone-archive");
98
98
  const milestone_unarchive_1 = require("./commands/milestone-unarchive");
99
99
  const milestone_add_1 = require("./commands/milestone-add");
100
100
  const milestone_remove_1 = require("./commands/milestone-remove");
101
+ const milestone_triage_1 = require("./commands/milestone-triage");
101
102
  const milestone_summary_1 = require("./commands/milestone-summary");
102
103
  const milestone_reorder_1 = require("./commands/milestone-reorder");
103
104
  const milestone_move_1 = require("./commands/milestone-move");
105
+ const milestone_criteria_set_1 = require("./commands/milestone-criteria-set");
106
+ const milestone_criteria_list_1 = require("./commands/milestone-criteria-list");
107
+ const milestone_criteria_verdict_1 = require("./commands/milestone-criteria-verdict");
104
108
  const sprint_create_1 = require("./commands/sprint-create");
105
109
  const sprint_list_1 = require("./commands/sprint-list");
106
110
  const sprint_show_1 = require("./commands/sprint-show");
@@ -267,8 +271,9 @@ outcome
267
271
  .action(wrap(options => (0, outcome_1.outcomeRate)(options)));
268
272
  program
269
273
  .command('next')
270
- .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`.')
274
+ .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`. Use --claimable to restrict to the autonomy boundary (claimable ∩ unblocked ∩ in-budget).')
271
275
  .option('-n, --count <N>', 'Number of tasks to recommend (default 3)')
276
+ .option('--claimable', 'Only recommend agent-claimable tasks that are unblocked (F3) and within milestone budget (F2)')
272
277
  .action(wrap(options => (0, next_1.nextCommand)(options)));
273
278
  program
274
279
  .command('cost')
@@ -605,13 +610,14 @@ milestoneCmd
605
610
  .action(wrap((identifier, options) => (0, milestone_show_1.milestoneShow)(identifier, options)));
606
611
  milestoneCmd
607
612
  .command('update <identifier>')
608
- .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target. Use "" to clear nullable fields.')
613
+ .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target, --token-budget. Use "" to clear nullable fields.')
609
614
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
610
615
  .option('-n, --name <text>', 'New name')
611
616
  .option('-d, --description <text>', 'New description (empty string to clear)')
612
617
  .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
613
618
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
614
619
  .option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
620
+ .option('--token-budget <tokens>', 'Advisory token budget, positive integer (empty string to clear). Overview shows burn vs budget; over-budget raises a risk-queue alert — never a hard gate.')
615
621
  .action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
616
622
  milestoneCmd
617
623
  .command('delete <identifier>')
@@ -639,6 +645,13 @@ milestoneCmd
639
645
  .description('Unbind one or more tasks from a milestone in one call. Tasks not currently in the milestone are skipped (idempotent), never reassigned. <identifier> accepts a name or UUID; each <task> accepts LUM-N or UUID.')
640
646
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
641
647
  .action(wrap((identifier, tasks, options) => (0, milestone_remove_1.milestoneRemove)(identifier, tasks, options)));
648
+ milestoneCmd
649
+ .command('triage <identifier> <tasks...>')
650
+ .description('Batch-mark tasks agent-claimable within a milestone (steward triage, LUM-646). Choose a direction: --claimable or --not-claimable. Tasks not in the milestone are skipped. <identifier> accepts a name or UUID; each <task> accepts LUM-N or UUID. `lumo next --claimable` then surfaces only claimable ∩ unblocked ∩ in-budget tasks.')
651
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
652
+ .option('--claimable', 'Mark the listed tasks agent-claimable')
653
+ .option('--not-claimable', 'Mark the listed tasks NOT agent-claimable')
654
+ .action(wrap((identifier, tasks, options) => (0, milestone_triage_1.milestoneTriage)(identifier, tasks, options)));
642
655
  milestoneCmd
643
656
  .command('summary <identifier>')
644
657
  .description('Show the AI-generated summary for a milestone. Identifier accepts a milestone name or UUID. Prints "(no summary generated yet)" when none exists. Use --retry to queue regeneration before fetching.')
@@ -657,6 +670,30 @@ milestoneCmd
657
670
  .option('--before <ref>', 'Place <ref> immediately before this milestone')
658
671
  .option('--after <ref>', 'Place <ref> immediately after this milestone')
659
672
  .action(wrap((ref, options) => (0, milestone_move_1.milestoneMove)(ref, options)));
673
+ const milestoneCriteria = milestoneCmd
674
+ .command('criteria')
675
+ .description('Milestone-level exit criteria — the close contract (LUM-643). Drafted and adjudicated by the milestone steward (LUM-640) or a human; unresolved criteria refuse status→COMPLETED (human override via web only).');
676
+ milestoneCriteria
677
+ .command('set <identifier>')
678
+ .description('Submit the whole exit contract from a JSON file (full replace; adjudicated rows are tombstoned, their verdict trail survives). Default = steward/agent draft; --human records a transcribed HUMAN_EDIT revision. Locked with 409 once the milestone is COMPLETED.')
679
+ .requiredOption('--file <path>', 'JSON array of criteria: [{"statement","referentKind":"EXTERNAL_FACT"|"AGENT_CONSTRUCTED_STATE"|"PENDING_OUTCOME"}] — no verifierType/checkpointer (exit criteria are adjudicated, not machine-run)')
680
+ .option('--human', 'Record a human contract revision (HUMAN_EDIT) transcribed from the conversation')
681
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
682
+ .action(wrap((identifier, options) => (0, milestone_criteria_set_1.milestoneCriteriaSet)(identifier, options)));
683
+ milestoneCriteria
684
+ .command('list <identifier>')
685
+ .description('List a milestone’s exit criteria: id, verdict (MET | NOT_MET | PENDING), provenance, referent kind, statement — plus whether the close gate is open.')
686
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
687
+ .action(wrap((identifier, options) => (0, milestone_criteria_list_1.milestoneCriteriaList)(identifier, options)));
688
+ milestoneCriteria
689
+ .command('verdict <identifier> <criterion>')
690
+ .description('Adjudicate (裁定) one exit criterion: --met or --unmet, with optional --note. Grounding gate: --met on an EXTERNAL_FACT criterion requires --evidence (422 otherwise), and a PENDING_OUTCOME criterion can never be MET. <criterion> = id or ≥6-char id prefix from `criteria list`.')
691
+ .option('--met', 'Adjudicate the criterion MET')
692
+ .option('--unmet', 'Adjudicate the criterion NOT_MET')
693
+ .option('--note <text>', 'Adjudication note (why this verdict)')
694
+ .option('--evidence <pointer>', 'Evidence pointer backing a MET on EXTERNAL_FACT (URL, commit SHA, CI run, DB probe …)')
695
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
696
+ .action(wrap((identifier, criterion, options) => (0, milestone_criteria_verdict_1.milestoneCriteriaVerdict)(identifier, criterion, options)));
660
697
  const sprintCmd = program
661
698
  .command('sprint')
662
699
  .description('Inspect sprints from the terminal');
@@ -849,6 +886,8 @@ task
849
886
  .option('-a, --assignee <ref>', 'New assignee: email, name, or "me" (empty string to clear)')
850
887
  .option('--milestone <ref>', 'Milestone name (case-insensitive); empty string to unbind')
851
888
  .option('--sprint <ref>', 'Sprint number or UUID to bind the task to; empty string to unbind from current sprint')
889
+ .option('--claimable', 'Mark the task agent-claimable (surfaces in `lumo next --claimable`)')
890
+ .option('--not-claimable', 'Mark the task NOT agent-claimable (mutually exclusive with --claimable)')
852
891
  .option('--tag <name>', 'Set tags by name (bulk replace, repeatable)', collect, [])
853
892
  .option('--tag-id <cuid>', 'Set tags by id (bulk replace, repeatable)', collect, [])
854
893
  .option('--add-tag <name>', 'Add tag by name (repeatable)', collect, [])
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * LUM-646 (F4/自治边界): the agent-claimable autonomy gate for
4
+ * `lumo next --claimable`. Pure — no network/IO — so it can be unit-tested
5
+ * directly and kept out of the ranking comparator.
6
+ *
7
+ * A task is inside the autonomy boundary only when ALL THREE hold:
8
+ * 1. claimable === true — a steward/human triaged it (fail-closed)
9
+ * 2. no open blockers — F3 topology (blockedByOpenCount === 0)
10
+ * 3. milestone not over budget — F2 advisory budget (milestoneBudgetExceeded !== true)
11
+ *
12
+ * Budget is advisory and fail-open on the unknown: only a MEASURED overrun
13
+ * (`milestoneBudgetExceeded === true`) excludes a task. `false` / `null` /
14
+ * absent (no budget set, or unmeasured burn) never excludes — mirroring the
15
+ * LUM-644 rule that the budget never fabricates an all-clear OR a false alert.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.filterClaimable = filterClaimable;
19
+ exports.formatClaimableExclusions = formatClaimableExclusions;
20
+ /** A new server always sends a boolean claimable; an old one omits it. */
21
+ function serverSupportsClaimable(tasks) {
22
+ return tasks.some(t => typeof t.claimable === 'boolean');
23
+ }
24
+ /**
25
+ * Apply the three-part autonomy gate. Each excluded task is counted under its
26
+ * FIRST failing reason (claimable → blocked → budget), so the buckets sum to
27
+ * `tasks.length - kept.length` exactly.
28
+ */
29
+ function filterClaimable(tasks) {
30
+ const excluded = {
31
+ notClaimable: 0,
32
+ blocked: 0,
33
+ overBudget: 0,
34
+ };
35
+ if (!serverSupportsClaimable(tasks)) {
36
+ return { supported: false, kept: [], excluded };
37
+ }
38
+ const kept = [];
39
+ for (const t of tasks) {
40
+ if (t.claimable !== true) {
41
+ excluded.notClaimable++;
42
+ continue;
43
+ }
44
+ if ((t.blockedByOpenCount ?? 0) > 0) {
45
+ excluded.blocked++;
46
+ continue;
47
+ }
48
+ if (t.milestoneBudgetExceeded === true) {
49
+ excluded.overBudget++;
50
+ continue;
51
+ }
52
+ kept.push(t);
53
+ }
54
+ return { supported: true, kept, excluded };
55
+ }
56
+ /**
57
+ * Render the exclusion tally as a one-line, human-readable clause (omitting
58
+ * zero buckets). Returns '' when nothing was excluded. Pure.
59
+ */
60
+ function formatClaimableExclusions(excluded) {
61
+ const parts = [];
62
+ if (excluded.notClaimable > 0) {
63
+ parts.push(`${excluded.notClaimable} not triaged claimable`);
64
+ }
65
+ if (excluded.blocked > 0) {
66
+ parts.push(`${excluded.blocked} blocked by open deps`);
67
+ }
68
+ if (excluded.overBudget > 0) {
69
+ parts.push(`${excluded.overBudget} over milestone budget`);
70
+ }
71
+ return parts.join(', ');
72
+ }
@@ -146,6 +146,10 @@ _now = new Date()) {
146
146
  body.linkedResourcesSection,
147
147
  body.reviewTodosSection,
148
148
  body.layer2ReviewSection,
149
+ // LUM-647: prior-milestone learnings for a resumed STEWARD session. Only the
150
+ // steward branch of the session-start hook populates this; task-bound
151
+ // sessions leave it empty.
152
+ body.stewardMemorySection,
149
153
  ]);
150
154
  if (envelope)
151
155
  lines.push(envelope);
@@ -5,8 +5,9 @@
5
5
  * of human-readable reason factors.
6
6
  *
7
7
  * Ordering is lexicographic (explainable over a tunable magic score), honoring
8
- * "priority first": priority → active-sprint membership → dueDate → in-flight
9
- * statusupdatedAt desc tiebreak.
8
+ * "priority first": priority → active-sprint membership → dependency topology
9
+ * (unblocked before blocked, then most open tasks released) dueDate
10
+ * in-flight status → updatedAt desc tiebreak.
10
11
  */
11
12
  Object.defineProperty(exports, "__esModule", { value: true });
12
13
  exports.rankTasks = rankTasks;
@@ -38,11 +39,28 @@ function statusRank(status) {
38
39
  function inActiveSprint(task, activeSprintIds) {
39
40
  return task.sprintId !== null && activeSprintIds.has(task.sprintId);
40
41
  }
42
+ /** Missing counts (older server) read as 0 — no topology signal, no reorder. */
43
+ function blockedByOpen(task) {
44
+ return task.blockedByOpenCount ?? 0;
45
+ }
46
+ function unblocksOpen(task) {
47
+ return task.unblocksOpenCount ?? 0;
48
+ }
41
49
  function deriveReasons(task, activeSprintIds, now) {
42
50
  const reasons = [task.priority];
43
51
  if (inActiveSprint(task, activeSprintIds)) {
44
52
  reasons.push('active sprint');
45
53
  }
54
+ const blocked = blockedByOpen(task);
55
+ const unblocks = unblocksOpen(task);
56
+ if (blocked > 0) {
57
+ reasons.push(blocked === 1
58
+ ? 'blocked by 1 open dep'
59
+ : `blocked by ${blocked} open deps`);
60
+ }
61
+ else if (unblocks > 0) {
62
+ reasons.push(unblocks === 1 ? 'unblocks 1 task' : `unblocks ${unblocks} tasks`);
63
+ }
46
64
  if (task.dueDate) {
47
65
  const day = task.dueDate.slice(0, 10);
48
66
  const due = new Date(task.dueDate).getTime();
@@ -64,6 +82,16 @@ function rankTasks(tasks, activeSprintIds, now) {
64
82
  const sb = inActiveSprint(b, activeSprintIds) ? 0 : 1;
65
83
  if (sa !== sb)
66
84
  return sa - sb;
85
+ // Dependency topology (LUM-645): workable-now before blocked, then the
86
+ // task that releases the most open work. Both 0 when the server doesn't
87
+ // send counts — the comparator falls through unchanged.
88
+ const ba = blockedByOpen(a) > 0 ? 1 : 0;
89
+ const bb = blockedByOpen(b) > 0 ? 1 : 0;
90
+ if (ba !== bb)
91
+ return ba - bb;
92
+ const ub = unblocksOpen(b) - unblocksOpen(a);
93
+ if (ub !== 0)
94
+ return ub;
67
95
  const dv = dueValue(a.dueDate) - dueValue(b.dueDate);
68
96
  if (dv !== 0 && !Number.isNaN(dv))
69
97
  return dv;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",