@lumoai/cli 1.50.0 → 1.51.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.
@@ -126,6 +126,7 @@ lumo fidelity show LUM-42 --json
126
126
  - `lumo milestone add/remove <id> <task...>` — batch bind/unbind tasks
127
127
  - `lumo milestone summary [--retry]` — AI retro
128
128
  - `lumo milestone reorder/move` — manual ordering
129
+ - `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
130
 
130
131
  **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
132
 
@@ -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.
@@ -177,10 +177,14 @@ one-line reason. Read-only — it does **not** bind or load context. Pick one fr
177
177
  the list, then run `lumo session attach <LUM-N>` + `lumo task context <LUM-N>`.
178
178
 
179
179
  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.
180
+ membership**, then **dependency topology** (LUM-645: tasks with an open
181
+ CONFIRMED blocker sort after workable ones; among workable peers the task that
182
+ directly unblocks the most open tasks ranks first), then **due date** (earlier
183
+ first), then in-flight status (IN_PROGRESS / IN_REVIEW ahead of TODO). DONE
184
+ tasks are excluded. The active sprint lookup is best-effort — if it fails the
185
+ command still recommends, just without the sprint boost. The topology counts
186
+ come from the server (`/api/tasks/me`); against an older server without them
187
+ the order is simply the pre-topology ranking.
184
188
 
185
189
  | Flag | Type | Notes |
186
190
  | ----------------- | ------- | ----------------------------------------------------------------------- |
@@ -199,9 +203,9 @@ Top 3 recommended tasks (of 12 open):
199
203
  1. LUM-42 IN_PROGRESS URGENT Fix Slack OAuth redirect
200
204
  ↳ URGENT · active sprint · due 2026-06-03 (overdue) · in progress
201
205
  2. LUM-48 TODO HIGH Investigate slow query
202
- ↳ HIGH · active sprint
206
+ ↳ HIGH · active sprint · unblocks 2 tasks
203
207
  3. LUM-12 TODO MEDIUM Add rate limiting
204
- ↳ MEDIUM · due 2026-06-10
208
+ ↳ MEDIUM · blocked by 1 open dep · due 2026-06-10
205
209
 
206
210
  Next: lumo session attach LUM-42 && lumo task context LUM-42
207
211
  ```
@@ -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
+ }
@@ -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)();
@@ -101,6 +101,9 @@ const milestone_remove_1 = require("./commands/milestone-remove");
101
101
  const milestone_summary_1 = require("./commands/milestone-summary");
102
102
  const milestone_reorder_1 = require("./commands/milestone-reorder");
103
103
  const milestone_move_1 = require("./commands/milestone-move");
104
+ const milestone_criteria_set_1 = require("./commands/milestone-criteria-set");
105
+ const milestone_criteria_list_1 = require("./commands/milestone-criteria-list");
106
+ const milestone_criteria_verdict_1 = require("./commands/milestone-criteria-verdict");
104
107
  const sprint_create_1 = require("./commands/sprint-create");
105
108
  const sprint_list_1 = require("./commands/sprint-list");
106
109
  const sprint_show_1 = require("./commands/sprint-show");
@@ -605,13 +608,14 @@ milestoneCmd
605
608
  .action(wrap((identifier, options) => (0, milestone_show_1.milestoneShow)(identifier, options)));
606
609
  milestoneCmd
607
610
  .command('update <identifier>')
608
- .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target. Use "" to clear nullable fields.')
611
+ .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target, --token-budget. Use "" to clear nullable fields.')
609
612
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
610
613
  .option('-n, --name <text>', 'New name')
611
614
  .option('-d, --description <text>', 'New description (empty string to clear)')
612
615
  .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
613
616
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
614
617
  .option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
618
+ .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
619
  .action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
616
620
  milestoneCmd
617
621
  .command('delete <identifier>')
@@ -657,6 +661,30 @@ milestoneCmd
657
661
  .option('--before <ref>', 'Place <ref> immediately before this milestone')
658
662
  .option('--after <ref>', 'Place <ref> immediately after this milestone')
659
663
  .action(wrap((ref, options) => (0, milestone_move_1.milestoneMove)(ref, options)));
664
+ const milestoneCriteria = milestoneCmd
665
+ .command('criteria')
666
+ .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).');
667
+ milestoneCriteria
668
+ .command('set <identifier>')
669
+ .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.')
670
+ .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)')
671
+ .option('--human', 'Record a human contract revision (HUMAN_EDIT) transcribed from the conversation')
672
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
673
+ .action(wrap((identifier, options) => (0, milestone_criteria_set_1.milestoneCriteriaSet)(identifier, options)));
674
+ milestoneCriteria
675
+ .command('list <identifier>')
676
+ .description('List a milestone’s exit criteria: id, verdict (MET | NOT_MET | PENDING), provenance, referent kind, statement — plus whether the close gate is open.')
677
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
678
+ .action(wrap((identifier, options) => (0, milestone_criteria_list_1.milestoneCriteriaList)(identifier, options)));
679
+ milestoneCriteria
680
+ .command('verdict <identifier> <criterion>')
681
+ .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`.')
682
+ .option('--met', 'Adjudicate the criterion MET')
683
+ .option('--unmet', 'Adjudicate the criterion NOT_MET')
684
+ .option('--note <text>', 'Adjudication note (why this verdict)')
685
+ .option('--evidence <pointer>', 'Evidence pointer backing a MET on EXTERNAL_FACT (URL, commit SHA, CI run, DB probe …)')
686
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
687
+ .action(wrap((identifier, criterion, options) => (0, milestone_criteria_verdict_1.milestoneCriteriaVerdict)(identifier, criterion, options)));
660
688
  const sprintCmd = program
661
689
  .command('sprint')
662
690
  .description('Inspect sprints from the terminal');
@@ -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.51.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",