@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.
- package/assets/skill/SKILL.md +4 -1
- package/assets/skill/references/milestones.md +79 -8
- package/assets/skill/references/sessions.md +30 -2
- package/assets/skill/references/tasks.md +10 -6
- package/dist/cli/src/commands/milestone-criteria-list.js +80 -0
- package/dist/cli/src/commands/milestone-criteria-set.js +145 -0
- package/dist/cli/src/commands/milestone-criteria-verdict.js +129 -0
- package/dist/cli/src/commands/milestone-show.js +9 -2
- package/dist/cli/src/commands/milestone-update.js +34 -1
- package/dist/cli/src/commands/session-attach.js +111 -11
- package/dist/cli/src/commands/session-status.js +8 -0
- package/dist/cli/src/index.js +33 -4
- package/dist/cli/src/lib/rank-tasks.js +30 -2
- package/dist/shared/src/gate-map.js +133 -0
- package/package.json +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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 **
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
17
|
-
* Re-attaching to the *same* task is an idempotent
|
|
18
|
-
* Attaching to a
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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(
|
|
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.
|
|
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` +
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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.
|
|
303
|
-
.
|
|
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
|
|
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 →
|
|
9
|
-
*
|
|
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
|
+
}
|