@lumoai/cli 1.49.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
 
@@ -157,7 +158,8 @@ lumo fidelity show LUM-42 --json
157
158
  **Sessions** — see [sessions.md](references/sessions.md)
158
159
 
159
160
  - `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 status`show current binding
161
+ - `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 status` — show current binding (a task, or the milestone + STEWARD role)
161
163
  - 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)
162
164
  - Git-suggest at session start (suggests `session attach`, never auto-binds) + Layer-2 project-memory review — see the reference
163
165
 
@@ -192,6 +194,7 @@ Don't guess these:
192
194
 
193
195
  - No `lumo session start` — binding is `lumo session attach <LUM-N>`
194
196
  - No `lumo session detach` — the session↔task binding is a lifetime lock; to work on a different task, start a new Claude Code session
197
+ - No steward "role switch" or un-steward — `session attach --steward` is the same lifetime lock on a milestone; a steward session never converts to an implementation session (and there is no CLI path to clear its boundary crossings)
195
198
  - No `lumo task delete` — tasks can't be deleted from the CLI (web UI only)
196
199
  - No `lumo task artifact edit` — it's `lumo task artifact update`
197
200
  - No `lumo auth status` — identity check is `lumo whoami`
@@ -73,6 +73,8 @@ Accepts UUID or name. With a name, `--project <ref>` is required when the worksp
73
73
 
74
74
  Prints a key:value header (name, status, **health**, dates, project, description), task counts, and the full task table under the milestone. The `Health:` line shows the same target-date risk light as `milestone list` (`ON-TRACK` / `AT-RISK` / `OVERDUE`, or `-` when none applies).
75
75
 
76
+ **Staleness hints (LUM-624):** the `Status:` line is annotated `(stale — task progress indicates <STATUS>; auto-updating)` when the stored status lags the task-derived one (the server self-heals the drift in the background — a re-read shows the corrected status), and the `Target:` line is annotated `(no target date — schedule health unavailable)` for an in-flight milestone with no target date.
77
+
76
78
  It also prints a **Sprint coverage** section (above the task table) listing which
77
79
  sprints the milestone's tasks span — each row shows the sprint number, status, name,
78
80
  and `done/total` progress — plus an `Unscheduled` line counting milestone tasks not in any
@@ -96,23 +98,92 @@ lumo milestone show 11111111-2222-3333-4444-555555555555
96
98
 
97
99
  ### `lumo milestone update <identifier>` — patch a milestone
98
100
 
99
- | Flag | Type | Notes |
100
- | ---------------------- | ------ | ---------------------------------------------------------------- |
101
- | `--project <ref>` | string | Required when identifier is a name and workspace has >1 project. |
102
- | `-n, --name <text>` | string | Cannot be empty. |
103
- | `-d, --description <>` | string | `--description ""` clears. |
104
- | `-s, --status <value>` | enum | `planned \| active \| completed \| cancelled`. |
105
- | `--start <date>` | string | `--start ""` clears. |
106
- | `--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. |
107
110
 
108
111
  At least one field required.
109
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
+
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.
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
+
110
121
  ```bash
111
122
  lumo milestone update "Q3 Launch" --status active
112
123
  lumo milestone update "Q3 Launch" --target 2026-09-15
113
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
114
153
  ```
115
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"
161
+ ```
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
+
116
187
  ### `lumo milestone delete <identifier>` — delete a milestone
117
188
 
118
189
  Requires `--yes`. No interactive prompt — CLI is agent-friendly. Tasks under the milestone keep their data; their `milestoneId` is cleared.
@@ -113,12 +113,40 @@ A successful `session attach` also runs a **best-effort team-memory downsync** f
113
113
  Error: this session is permanently bound to LUM-7 "Other task". A session works one task for its lifetime — start a new Claude Code session to work on LUM-42.
114
114
  ```
115
115
 
116
- There is no `--force` and no `session detach`. To work on a different task, open a new terminal / Claude Code session and run `lumo session attach <new-task>` there.
116
+ There is no `--force` and no `session detach`. To work on a different task, open a new terminal / Claude Code session and run `lumo session attach <new-task>` there. A session already bound as **STEWARD** of a milestone (below) also 409s here — it can never attach a task.
117
117
 
118
118
  #### When to suggest
119
119
 
120
120
  If `session attach` returns 409, do not retry or look for a workaround — start a fresh Claude Code session for the target task.
121
121
 
122
+ ### `lumo session attach --steward <milestone>` — bind as a STEWARD (governance) session (LUM-640)
123
+
124
+ The **second legal session form**: instead of a task, the session binds a **milestone** and becomes a governance role — it plans and accepts, it never implements.
125
+
126
+ ```bash
127
+ lumo session attach --steward "Agent-Native Milestones" # by name (case-insensitive)
128
+ lumo session attach --steward agent-native-milestones # by slug
129
+ lumo session attach --steward cmxyz123... # by milestone id
130
+ ```
131
+
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
+ - **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
+ - **Multiple stewards per milestone are allowed** (acceptance is multi-round by nature); sessions don't conflict.
135
+
136
+ **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
+ **Blocked (implementation — first layer, API gate):**
139
+
140
+ - `lumo verify` → **409, no round burned, no VerificationRun written**. The error names the milestone and the remedy (start an implementation session and `lumo session attach <task>`). Do not retry from the steward session.
141
+ - Moving a task to `in_progress` with **yourself as the effective assignee** (whether already assigned to you or assigned in the same call) → 409. Dispatching to _someone else_ is planning and passes.
142
+ - Red line unchanged: a steward writes no PASS of any kind; DONE stays human-only.
143
+
144
+ **Backstop (second layer, detector):** commits / PR creations produced during a steward session anyway (the CLI can't stop local git) are recorded as **`STEWARD_IMPLEMENTATION_ARTIFACT` boundary crossings against the milestone** — fail-closed (an unparseable Bash payload records an _unconfirmed_ crossing rather than nothing), disposition stays **web + human-only**, and there is **no CLI clear path** (`crossing explain` remains an appeal, never a clear).
145
+
146
+ #### When to suggest
147
+
148
+ The user asks to "review/accept/plan a milestone", to act as reviewer/steward across tasks, or wants a governance session that must not implement. If a steward session needs code changed, dispatch the task to an implementation owner (or tell the user to open a new implementation session) — never work around the gate.
149
+
122
150
  ### Parallel sessions
123
151
 
124
152
  Each Claude Code session has its own `CLAUDE_CODE_SESSION_ID`. Two terminals running `claude code` and binding to different tasks will not interfere — bindings are scoped per session row server-side.
@@ -129,7 +157,7 @@ Each Claude Code session has its own `CLAUDE_CODE_SESSION_ID`. Two terminals run
129
157
  lumo session status
130
158
  ```
131
159
 
132
- Prints which task the current Claude Code session is bound to, or "(no task)" if none. Requires `$CLAUDE_CODE_SESSION_ID` (must run inside Claude Code).
160
+ Prints what the current Claude Code session is bound to — a task, a milestone (`Role: STEWARD (governance — plan & accept only)`), or "(no task)" if none. Requires `$CLAUDE_CODE_SESSION_ID` (must run inside Claude Code).
133
161
 
134
162
  #### When to suggest
135
163
 
@@ -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
+ }
@@ -44,13 +44,19 @@ function formatMilestoneShow(m, tasks) {
44
44
  m.taskCounts.IN_PROGRESS +
45
45
  m.taskCounts.IN_REVIEW +
46
46
  m.taskCounts.DONE;
47
+ const statusLine = m.staleness?.statusDrift
48
+ ? `Status: ${m.status} (stale — task progress indicates ${(0, sanitize_1.sanitizeField)(m.staleness.statusDrift)}; auto-updating)`
49
+ : `Status: ${m.status}`;
50
+ const targetLine = m.staleness?.datesMissing
51
+ ? `Target: ${fmtDate(m.targetDate)} (no target date — schedule health unavailable)`
52
+ : `Target: ${fmtDate(m.targetDate)}`;
47
53
  const lines = [
48
54
  `Milestone: ${(0, sanitize_1.sanitizeField)(m.name)}`,
49
- `Status: ${m.status}`,
55
+ statusLine,
50
56
  `Archived: ${m.archivedAt ? m.archivedAt.slice(0, 10) : 'no'}`,
51
57
  `Health: ${fmtHealth(m.health)}`,
52
58
  `Start: ${fmtDate(m.startDate)}`,
53
- `Target: ${fmtDate(m.targetDate)}`,
59
+ targetLine,
54
60
  `Project: ${(0, sanitize_1.sanitizeField)(m.projectName)}`,
55
61
  `Description:`,
56
62
  ` ${m.description && m.description.length > 0 ? (0, sanitize_1.sanitizeField)(m.description) : '-'}`,
@@ -136,6 +142,7 @@ async function milestoneShow(identifier, opts) {
136
142
  projectName,
137
143
  taskCounts: milestone.taskCounts,
138
144
  health: milestone.health,
145
+ staleness: milestone.staleness,
139
146
  sprintCoverage: milestone.sprintCoverage,
140
147
  }, tasks) + '\n');
141
148
  }
@@ -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)();
@@ -7,21 +7,30 @@ const sanitize_1 = require("../lib/sanitize");
7
7
  const resolve_project_1 = require("../lib/resolve-project");
8
8
  const memory_auto_1 = require("../lib/memory-auto");
9
9
  /**
10
- * `lumo session attach <identifier>` — bind the currently-running
11
- * Claude Code session to a task.
10
+ * `lumo session attach <identifier> [--steward]` — bind the currently-running
11
+ * Claude Code session to a task, or (with `--steward`) to a MILESTONE as a
12
+ * STEWARD (governance) session (LUM-640).
12
13
  *
13
14
  * Required environment: `CLAUDE_CODE_SESSION_ID` (set automatically by
14
15
  * Claude Code). Must be invoked from inside a Claude Code session.
15
16
  *
16
- * The binding is a **lifetime lock** (LUM-459): `Session.taskId` is write-once.
17
- * Re-attaching to the *same* task is an idempotent no-op (re-emits context).
18
- * Attaching to a *different* task is refused with HTTP 409 — there is no
19
- * `--force` and no `session detach`; a different task requires a brand-new
20
- * Claude Code session.
17
+ * Both forms are a **lifetime lock** (LUM-459/LUM-640): the binding is
18
+ * write-once. Re-attaching to the *same* task/milestone is an idempotent
19
+ * no-op. Attaching to anything else — a different task, a different
20
+ * milestone, a task after a steward bind, a milestone after a task bind
21
+ * is refused with HTTP 409; there is no `--force` and no `session detach`;
22
+ * a different binding requires a brand-new Claude Code session.
23
+ *
24
+ * A STEWARD session plans and accepts only: task create/update, criteria
25
+ * set, deps confirm/dismiss, verdict --fail, milestone add/remove and all
26
+ * reads pass; `lumo verify` and self-dispatch are refused with 409, and
27
+ * implementation artifacts (commits/PRs) are recorded as boundary crossings.
21
28
  */
22
- async function sessionAttach(identifier) {
29
+ async function sessionAttach(identifier, options) {
23
30
  if (!identifier) {
24
- console.error('Error: missing <identifier>. Usage: lumo session attach <LUM-42>');
31
+ console.error(options?.steward
32
+ ? 'Error: missing <identifier>. Usage: lumo session attach --steward <milestone id | name | slug>'
33
+ : 'Error: missing <identifier>. Usage: lumo session attach <LUM-42>');
25
34
  return 1;
26
35
  }
27
36
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
@@ -35,6 +44,14 @@ async function sessionAttach(identifier) {
35
44
  console.error('Error: not logged in. Run `lumo auth login` first.');
36
45
  return 1;
37
46
  }
47
+ if (options?.steward) {
48
+ return stewardAttach({
49
+ identifier,
50
+ sessionId,
51
+ apiUrl: (0, api_1.resolveAuthedApiUrl)(creds.apiUrl),
52
+ token: creds.token,
53
+ });
54
+ }
38
55
  const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
39
56
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-task`;
40
57
  let res;
@@ -70,12 +87,17 @@ async function sessionAttach(identifier) {
70
87
  console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
71
88
  return 1;
72
89
  }
73
- // Lifetime lock (LUM-459): the session is permanently bound to another task.
90
+ // Lifetime lock (LUM-459): the session is permanently bound to another task
91
+ // or (LUM-640) it is a STEWARD session bound to a milestone.
74
92
  if (res.status === 409) {
75
93
  let current = 'another task';
94
+ let stewardMilestone = null;
76
95
  try {
77
96
  const data = (await res.json());
78
- if (data.currentTaskIdentifier) {
97
+ if (data.currentMilestoneName) {
98
+ stewardMilestone = (0, sanitize_1.sanitizeField)(data.currentMilestoneName);
99
+ }
100
+ else if (data.currentTaskIdentifier) {
79
101
  current = data.currentTaskTitle
80
102
  ? `${data.currentTaskIdentifier} "${(0, sanitize_1.sanitizeField)(data.currentTaskTitle)}"`
81
103
  : data.currentTaskIdentifier;
@@ -84,6 +106,12 @@ async function sessionAttach(identifier) {
84
106
  catch {
85
107
  // fall through with the generic phrasing
86
108
  }
109
+ if (stewardMilestone) {
110
+ console.error(`Error: this session is a STEWARD (governance) session for milestone "${stewardMilestone}" — ` +
111
+ 'it cannot attach a task. Start a new Claude Code session to ' +
112
+ `implement ${identifier}.`);
113
+ return 1;
114
+ }
87
115
  console.error(`Error: this session is permanently bound to ${current}. ` +
88
116
  'A session works one task for its lifetime — start a new Claude Code ' +
89
117
  `session to work on ${identifier}.`);
@@ -139,3 +167,75 @@ async function sessionAttach(identifier) {
139
167
  // best-effort — the bind already succeeded; never surface a sync error here
140
168
  }
141
169
  }
170
+ /**
171
+ * LUM-640: the `--steward` form — bind this session to a MILESTONE as a
172
+ * governance session. Same lifetime-lock error surface as the task form; the
173
+ * success output spells out the allowed/blocked action split so the agent
174
+ * knows its lane without a round-trip to the docs.
175
+ */
176
+ async function stewardAttach(args) {
177
+ const { identifier, sessionId, apiUrl, token } = args;
178
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}/bind-milestone`;
179
+ let res;
180
+ try {
181
+ res = await fetch(url, {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Content-Type': 'application/json',
185
+ Authorization: `Bearer ${token}`,
186
+ },
187
+ body: JSON.stringify({ milestoneRef: identifier }),
188
+ });
189
+ }
190
+ catch (err) {
191
+ const msg = err instanceof Error ? err.message : String(err);
192
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
193
+ return 1;
194
+ }
195
+ if (res.status === 401) {
196
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
197
+ return 1;
198
+ }
199
+ // 400 (ambiguous reference) and 404 (no match) carry actionable server
200
+ // messages — print them verbatim (sanitized).
201
+ if (res.status === 400 || res.status === 404) {
202
+ let message = res.status === 404 ? 'Not found' : 'Bad request';
203
+ try {
204
+ const data = (await res.json());
205
+ if (data.error)
206
+ message = data.error;
207
+ }
208
+ catch {
209
+ // fall through
210
+ }
211
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
212
+ return 1;
213
+ }
214
+ // Lifetime lock: already task-bound, or steward of a different milestone.
215
+ if (res.status === 409) {
216
+ let message = 'this session is already permanently bound';
217
+ try {
218
+ const data = (await res.json());
219
+ if (data.error)
220
+ message = data.error;
221
+ }
222
+ catch {
223
+ // fall through with the generic phrasing
224
+ }
225
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)} ` +
226
+ 'A session holds one binding for its lifetime — start a new Claude ' +
227
+ 'Code session for a different task or milestone.');
228
+ return 1;
229
+ }
230
+ if (!res.ok) {
231
+ console.error(`Error: bind-milestone failed (HTTP ${res.status})`);
232
+ return 1;
233
+ }
234
+ const body = (await res.json());
235
+ console.log(`Attached session ${sessionId} as STEWARD of milestone "${(0, sanitize_1.sanitizeField)(body.milestoneName)}"`);
236
+ console.log('');
237
+ console.log('Governance role — plan & accept only (lifetime lock):');
238
+ console.log(' allowed: task create/update · criteria set (incl. --human) · deps confirm/dismiss · verdict --fail · milestone add/remove · all reads');
239
+ console.log(' blocked: lumo verify (409, no round burned) · moving a task to in_progress assigned to yourself · attaching a task');
240
+ console.log(' note: commits/PRs produced in this session are recorded as boundary crossings (human-reviewed on the web)');
241
+ }
@@ -45,6 +45,14 @@ async function sessionStatus() {
45
45
  return 1;
46
46
  }
47
47
  const data = (await res.json());
48
+ // LUM-640: the steward form — milestone-bound governance session.
49
+ if (data.role === 'STEWARD' && data.milestoneName) {
50
+ process.stdout.write(`Session ${sessionId}\n` +
51
+ ` Role: STEWARD (governance — plan & accept only)\n` +
52
+ ` Bound to: milestone "${(0, sanitize_1.sanitizeField)(data.milestoneName)}"\n` +
53
+ ` Events: ${data.eventCount}\n`);
54
+ return;
55
+ }
48
56
  if (data.taskIdentifier && data.taskTitle) {
49
57
  process.stdout.write(`Session ${sessionId}\n` +
50
58
  ` Bound to: ${data.taskIdentifier} "${(0, sanitize_1.sanitizeField)(data.taskTitle)}"\n` +
@@ -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");
@@ -299,11 +302,12 @@ const session = program
299
302
  .description('Manage per-terminal coding-session context');
300
303
  session
301
304
  .command('attach <identifier>')
302
- .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task. The binding is a lifetime lock: re-attaching to the same task is a no-op, attaching to a different task is refused — start a new session for a different task.')
303
- .action(wrap(identifier => (0, session_attach_1.sessionAttach)(identifier)));
305
+ .description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task — or, with --steward, to a MILESTONE as a governance session (LUM-640: plan/accept only). Either binding is a lifetime lock: re-attaching the same target is a no-op, anything else is refused — start a new session instead.')
306
+ .option('--steward', 'Bind as a STEWARD (governance) session to a milestone (id, name, or slug) instead of a task. Steward sessions plan and accept only: lumo verify is refused (409, no round burned), self-dispatch to in_progress is refused, and implementation commits/PRs are recorded as boundary crossings.')
307
+ .action(wrap((identifier, options) => (0, session_attach_1.sessionAttach)(identifier, options)));
304
308
  session
305
309
  .command('status')
306
- .description('Show the task currently bound to this Claude Code session (or "no task" if none).')
310
+ .description('Show what this Claude Code session is bound to: a task, a milestone (STEWARD governance session), or nothing.')
307
311
  .action(wrap(() => (0, session_status_1.sessionStatus)()));
308
312
  const task = program
309
313
  .command('task')
@@ -604,13 +608,14 @@ milestoneCmd
604
608
  .action(wrap((identifier, options) => (0, milestone_show_1.milestoneShow)(identifier, options)));
605
609
  milestoneCmd
606
610
  .command('update <identifier>')
607
- .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.')
608
612
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
609
613
  .option('-n, --name <text>', 'New name')
610
614
  .option('-d, --description <text>', 'New description (empty string to clear)')
611
615
  .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
612
616
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
613
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.')
614
619
  .action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
615
620
  milestoneCmd
616
621
  .command('delete <identifier>')
@@ -656,6 +661,30 @@ milestoneCmd
656
661
  .option('--before <ref>', 'Place <ref> immediately before this milestone')
657
662
  .option('--after <ref>', 'Place <ref> immediately after this milestone')
658
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)));
659
688
  const sprintCmd = program
660
689
  .command('sprint')
661
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;
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /**
3
+ * Static gate map (LUM-614) — the single source of truth that classifies the
4
+ * engineer-facing mechanical gates into a stable key set, splits them by how
5
+ * their signal is produced, and (for execution gates) carries the command
6
+ * patterns that recognize an executed check in the hook-event stream.
7
+ *
8
+ * Shared by the read-model that derives a delivery's gate audit (server) and
9
+ * the delivery-card panel that renders it (app). It deliberately carries NO
10
+ * status — status is derived per delivery from HookEvent rows, never stored.
11
+ *
12
+ * The class → provenance split seeds the fidelity independence axis: a detection
13
+ * gate is something Lumo derives itself from the tool-intent stream it already
14
+ * ingests (independent of the agent). An execution gate's signal can come from
15
+ * EITHER source: the agent's own tool runs (self-reported — it chose to run the
16
+ * check and could skip it) OR, as of LUM-616, an independent CI run read back
17
+ * from GitHub (check-runs / commit-statuses), which the agent cannot skip or
18
+ * fake. So execution-gate provenance is no longer a pure function of class — it
19
+ * is carried per produced signal (see NormalizedGateEvent.provenance in the
20
+ * gate-audit read-model); `provenanceForClass` is only the not-run/not-configured
21
+ * default. Detection-gate provenance is still class-fixed.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.GATE_DEFINITIONS = exports.ALL_GATE_KEYS = void 0;
25
+ exports.provenanceForClass = provenanceForClass;
26
+ exports.gateDefinition = gateDefinition;
27
+ exports.classifyCommand = classifyCommand;
28
+ exports.classifyGateName = classifyGateName;
29
+ /** Provenance is a pure function of class — the only place the mapping lives. */
30
+ function provenanceForClass(cls) {
31
+ return cls === 'detection' ? 'independent_harness' : 'agent_self_reported';
32
+ }
33
+ const RAW_DEFINITIONS = [
34
+ // 'parse' (node --check) was dropped (LUM-616 follow-up): it's a strict subset
35
+ // of typecheck/build (which both parse before they can run), so a separate
36
+ // parse gate carried no independent information and was never sourced — it sat
37
+ // perpetually not_run. Removed rather than fabricated-green.
38
+ {
39
+ key: 'typecheck',
40
+ class: 'execution',
41
+ // No build-exclusion lookahead: classification is one-command → N gates
42
+ // (LUM-629), so `tsc && npm run build` reports typecheck AND build instead
43
+ // of needing build to win a single-choice tie.
44
+ commandPatterns: [/\btsc\b/, /typecheck/],
45
+ },
46
+ {
47
+ key: 'build',
48
+ class: 'execution',
49
+ commandPatterns: [/\bnext\s+build\b/, /\bnpm\s+run\s+build\b/],
50
+ },
51
+ {
52
+ key: 'test',
53
+ class: 'execution',
54
+ commandPatterns: [/\bjest\b/, /\bvitest\b/, /\bnpm\s+(run\s+)?test\b/],
55
+ },
56
+ // Detection-class gates were all removed (LUM-616 follow-up): detection belongs
57
+ // in boundary crossings, not audit-only gates.
58
+ // - 'git-hygiene' (committed secrets) ≡ the HARDCODED_SECRET crossing, which
59
+ // is detected server-side, surfaced, AND blocks DONE — strictly stronger.
60
+ // - 'diff-scope' (out-of-declared-scope edits) has no source of truth yet
61
+ // (Lumo has no "declared scope"); the right home is a future
62
+ // OUT_OF_SCOPE_EDIT crossing, not a perpetually not_configured gate.
63
+ // The 'detection' GateClass stays as scaffolding for a clean future re-add.
64
+ ];
65
+ const DEFINITIONS = RAW_DEFINITIONS.reduce((acc, raw) => {
66
+ acc[raw.key] = { ...raw, provenance: provenanceForClass(raw.class) };
67
+ return acc;
68
+ }, {});
69
+ exports.ALL_GATE_KEYS = RAW_DEFINITIONS.map(d => d.key);
70
+ exports.GATE_DEFINITIONS = exports.ALL_GATE_KEYS.map(k => DEFINITIONS[k]);
71
+ /** Look up a gate's definition by key. */
72
+ function gateDefinition(key) {
73
+ return DEFINITIONS[key];
74
+ }
75
+ /**
76
+ * Strip double- and single-quoted substrings from a command line, leaving only
77
+ * the unquoted invocation tokens. This is what stops a gate keyword that merely
78
+ * appears inside an *argument* — e.g. a `lumo task create "…parse/typecheck/
79
+ * build/test…"` description, or `echo "…run the test…"` — from being mistaken
80
+ * for an invocation of that gate (the LUM-614 prod false-positive, where a
81
+ * task-create description was classified as a passed typecheck and overwrote
82
+ * the real `tsc` signal). A real gate run names its tool unquoted, so it
83
+ * survives the strip.
84
+ */
85
+ function unquotedInvocation(command) {
86
+ return command.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, ' ');
87
+ }
88
+ /**
89
+ * Classify an executed shell command as execution gates — EVERY gate whose
90
+ * invocation tokens the command hits, in declaration order; empty when none.
91
+ * One command → N gates (LUM-629): agents routinely chain checks in a single
92
+ * shell line (`npx jest …; npx tsc --noEmit`), and single-choice classification
93
+ * silently dropped all but one gate's signal (the LUM-620 #770 test=not_run
94
+ * under-report). Matching still runs against the unquoted invocation only, so
95
+ * a gate keyword buried in quoted argument text never produces a false signal
96
+ * (LUM-614). Detection gates are never returned here — they're not derived
97
+ * from commands.
98
+ */
99
+ function classifyCommand(command) {
100
+ const invocation = unquotedInvocation(command);
101
+ return exports.GATE_DEFINITIONS.filter(def => def.commandPatterns.some(p => p.test(invocation))).map(def => def.key);
102
+ }
103
+ /**
104
+ * Patterns that classify a CI check-run / commit-status NAME (not a shell
105
+ * command) as an execution gate. LUM-616: the independent harness reads CI
106
+ * signals straight from GitHub (check-runs + commit-statuses) across ANY
107
+ * provider, and the only semantics GitHub exposes is each check's name plus its
108
+ * pass/fail — so mapping a named check to a gate is unavoidably name-based.
109
+ * Platform-agnostic on purpose (GitHub Actions, Vercel, CircleCI, Travis, …).
110
+ * First match wins; order = most specific (typecheck/test) before the build
111
+ * catch-all so a "build and test" job lands on the narrower gate.
112
+ */
113
+ const CHECK_NAME_PATTERNS = [
114
+ ['typecheck', [/\btypecheck\b/i, /\btsc\b/i, /type[\s-]?check/i]],
115
+ ['test', [/\btests?\b/i, /\bjest\b/i, /\bvitest\b/i, /\bspec\b/i]],
116
+ ['build', [/\bbuild\b/i, /\bvercel\b/i, /\bdeploy/i]],
117
+ ];
118
+ /**
119
+ * Classify a CI check / commit-status name as an execution gate, or null when
120
+ * it matches none — an unrecognized check is dropped, never coerced into a
121
+ * fabricated gate. A name carrying "comment" (e.g. "Vercel Preview Comments")
122
+ * is a bot annotation, not a CI check, and is rejected before pattern matching.
123
+ */
124
+ function classifyGateName(name) {
125
+ const n = name.trim();
126
+ if (!n || /comment/i.test(n))
127
+ return null;
128
+ for (const [key, patterns] of CHECK_NAME_PATTERNS) {
129
+ if (patterns.some(p => p.test(n)))
130
+ return key;
131
+ }
132
+ return null;
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.49.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",