@lumoai/cli 1.60.0 → 1.62.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.
@@ -145,7 +145,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
145
145
  - `lumo milestone summary [--retry]` — AI retro
146
146
  - `lumo milestone changelog <id> [--json]` — structured changelog from DONE tasks + merged PRs + frozen claimSnapshot (pure assembly, no LLM; runs on any status). Headline counts carry a baseline vs prior completed milestones (LUM-508); each claim shows provenance, no-PR/no-claim rendered explicitly (LUM-649)
147
147
  - `lumo milestone reorder/move` — manual ordering
148
- - `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
148
+ - `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) hold the LUM-714 derived completion fail-closed (no `completedAt`, no retro); human override is web-only, stamped as a persistent trail. `verdict --met` on `EXTERNAL_FACT` requires `--evidence` (422); `PENDING_OUTCOME` can never be MET
149
149
 
150
150
  ### Documents — CRUD/sharing → [docs.md](references/docs.md); **editing live docs** (raw/section/patch/append/diff/rebuild) → [doc-editing.md](references/doc-editing.md)
151
151
 
@@ -98,12 +98,13 @@ What the CLI does instead: when `task update <id> --status done --confirm` is re
98
98
  }
99
99
  ```
100
100
 
101
- | Field | Meaning |
102
- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
- | `message` | The server's 409 text, verbatim. |
104
- | `blockers` | One entry per item holding the gate: `SEND_BACK` (criterionId, statement, failedAtRound, rejectionReason), `BOUNDARY_CROSSING` (id, severity, category, detail), `SECURITY_FINDING` (findingId, severity, ruleId, filePath, line, prNumber, statement). Built from the same read models as `lumo task status`. |
105
- | `unconfirmed` | Enrichment reads that failed (`boundary-crossings read failed: HTTP 500`). Non-empty means the blocker list may be incomplete it never silently shrinks. |
106
- | `remediation` | One line per blocker kind present, naming only human-side, append-only or user-approved paths: re-run `lumo verify`, `lumo crossing explain`, `lumo crossing disposition` (exit-4 envelope, the user rules), fix-and-push, the web disposition panel. Never a ready-to-run approval flag. |
101
+ | Field | Meaning |
102
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
+ | `message` | The server's 409 text, verbatim. |
104
+ | `blockers` | One entry per item holding the gate: `SEND_BACK` (criterionId, statement, failedAtRound, rejectionReason), `BOUNDARY_CROSSING` (id, severity, category, detail), `SECURITY_FINDING` (findingId, severity, ruleId, filePath, line, prNumber, statement). Built from the same read models as `lumo task status`. A `BOUNDARY_CROSSING` entry appears only for crossings the server gate counts (LUM-771): advisory crossings never, and with the workspace gate off only HIGH ones. |
105
+ | `nonBlocking` | Open crossings the gate does NOT count (id, severity, category, detail, advisory) awareness only; do not ask the user to disposition them to get past DONE_BLOCKED. Rendered as a separate `Also open, not blocking DONE` list in the terminal (LUM-771). |
106
+ | `unconfirmed` | Enrichment reads that failed (`boundary-crossings read failed: HTTP 500`). Non-empty means the blocker list may be incomplete it never silently shrinks. |
107
+ | `remediation` | One line per blocker kind present, naming only human-side, append-only or user-approved paths: re-run `lumo verify`, `lumo crossing explain`, `lumo crossing disposition` (exit-4 envelope, the user rules), fix-and-push, the web disposition panel. Never a ready-to-run approval flag. |
107
108
 
108
109
  **Exit 4 vs exit 5:** 4 means "the user must approve, then you can proceed"; 5 means "a human must adjudicate — nothing on this command moves it". On exit 5, relay `blockers` and `remediation` to the user. Do not retry `task update`, do not look for a flag on it, and do not reword or delete a failed criterion to get past a send-back (that is tampering and is audited). For a `BOUNDARY_CROSSING` blocker the adjudication can happen in the terminal — `lumo crossing disposition <id> …` is itself an exit-4 command, so the user's ruling still comes through the protocol, never from you. Once every blocker is cleared, re-run the original `task update … --confirm` (its envelope was already approved). A 409 that is not a DONE gate (e.g. a permission refusal) keeps the plain exit-1 text on stderr.
109
110
 
@@ -28,7 +28,7 @@ Use `--search <text>` to filter to milestones whose **name or description** cont
28
28
  - `ON-TRACK` — on schedule (or all tasks done)
29
29
  - `AT-RISK` — completion lags elapsed time, or (no start date) the target is within ~7 days with work remaining
30
30
  - `OVERDUE` — past the target date with tasks still open
31
- - `-` — no light applies (status `COMPLETED`/`CANCELLED`, or no target date)
31
+ - `-` — no light applies (the milestone is completed or archived, or has no target date)
32
32
 
33
33
  ```bash
34
34
  lumo milestone list # one-project workspace (non-archived only)
@@ -71,9 +71,9 @@ On success: `Created milestone "Q3 Launch" <id>`.
71
71
 
72
72
  Accepts UUID or name. With a name, `--project <ref>` is required when the workspace has >1 project.
73
73
 
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).
74
+ Prints a key:value header (name, state, **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.
76
+ **Staleness hints (LUM-624/LUM-714):** the `State:` line is annotated `(stale — task progress indicates completed|active; auto-updating)` when the stored `completedAt` disagrees with the task counts (the server self-heals the drift in the background — a re-read shows the corrected state), and the `Target:` line is annotated `(no target date — schedule health unavailable)` for an in-flight milestone with no target date.
77
77
 
78
78
  It also prints a **Sprint coverage** section (above the task table) listing which
79
79
  sprints the milestone's tasks span — each row shows the sprint number, status, name,
@@ -111,7 +111,6 @@ lumo milestone show 11111111-2222-3333-4444-555555555555
111
111
  | `--project <ref>` | string | Required when identifier is a name and workspace has >1 project. |
112
112
  | `-n, --name <text>` | string | Cannot be empty. |
113
113
  | `-d, --description <>` | string | `--description ""` clears. |
114
- | `-s, --status <value>` | enum | `planned \| active \| completed \| cancelled`. |
115
114
  | `--start <date>` | string | `--start ""` clears. |
116
115
  | `--target <date>` | string | `--target ""` clears. |
117
116
  | `--token-budget <n>` | int | Advisory token budget (LUM-644), positive integer; `--token-budget ""` clears. |
@@ -122,12 +121,16 @@ At least one field required.
122
121
 
123
122
  **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.
124
123
 
125
- **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.
124
+ **There is no status flagstate is derived (LUM-714).** A milestone is `active`, `completed`, or `archived`, and only one of those is a choice:
126
125
 
127
- **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`.
126
+ - **completed** follows task completion. Every task DONE the rollup stamps `completedAt` and triggers the retro summary; a non-DONE task (re)appearing clears the stamp. Nothing you can pass to `update` moves it.
127
+ - **archived** is the one explicit human state (`milestone archive` / `unarchive`). Archived milestones are left alone by the rollup and hidden from the default list.
128
+
129
+ The old `PLANNED` / `ACTIVE` split is gone — a milestone whose tasks have not started is simply active — and `CANCELLED` is expressed by archiving (a milestone archived while incomplete reads as dropped, not delivered).
130
+
131
+ **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 — holds the LUM-714 derived completion fail-closed: all tasks DONE leaves the milestone active, `completedAt` unstamped, no retro. There is **no CLI/agent override**: the only escape hatch is a human overriding via the web (`POST /api/milestones/<id>/close-override`, Clerk identity only), 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`.
128
132
 
129
133
  ```bash
130
- lumo milestone update "Q3 Launch" --status active
131
134
  lumo milestone update "Q3 Launch" --target 2026-09-15
132
135
  lumo milestone update "Q3 Launch" --description ""
133
136
  lumo milestone update "Q3 Launch" --token-budget 50000000
@@ -162,7 +165,7 @@ lumo milestone criteria set "Q3 Launch" --file exit-criteria.json --human
162
165
 
163
166
  ### `lumo milestone criteria list <identifier>` — print the exit contract
164
167
 
165
- 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).`
168
+ 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 withholds the derived completion until adjudicated (human override only via web).`
166
169
 
167
170
  ```bash
168
171
  lumo milestone criteria list "Q3 Launch"
@@ -189,7 +192,7 @@ lumo milestone criteria verdict "Q3 Launch" cmexit0002 --unmet --note "read mode
189
192
  ### When to suggest `milestone criteria`
190
193
 
191
194
  - A steward session planning a milestone ("what does done mean for this milestone", "draft exit criteria") → `criteria set`.
192
- - 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.
195
+ - When a milestone's tasks are all DONE but it has not completed → `criteria list` to see what holds the gate, then `verdict` per criterion.
193
196
  - User asks "can we close this milestone / what's blocking the close" → `criteria list` (the gate-state line answers it).
194
197
 
195
198
  ### `lumo milestone delete <identifier>` — delete a milestone
@@ -287,7 +290,7 @@ Agent-Native Milestones: 1 marked claimable, 1 skipped
287
290
 
288
291
  Prints the AI-generated retrospective summary for a milestone (mirrors `sprint summary`). `<identifier>` accepts a milestone name or UUID; `--project <ref>` is required when the identifier is a name and the workspace has more than one project. When no summary exists yet the command prints `(no summary generated yet)`.
289
292
 
290
- A summary is generated automatically when a milestone transitions to `COMPLETED` (e.g. via `lumo milestone update <id> --status completed`). The generated report has sections `## Summary`, `## Delivered`, `## Outstanding` plus a one-line `tldr`. Use `--retry` to queue regeneration (e.g. after a failed generation) before fetching — regeneration is async, so the printed result may still be the previous summary or `(no summary generated yet)`.
293
+ A summary is generated automatically when a milestone completes (the LUM-714 derived transition: its last open task turns DONE, with the exit-criteria gate open). The generated report has sections `## Summary`, `## Delivered`, `## Outstanding` plus a one-line `tldr`. Use `--retry` to queue regeneration (e.g. after a failed generation) before fetching — regeneration is async, so the printed result may still be the previous summary or `(no summary generated yet)`.
291
294
 
292
295
  | Flag | Type | Notes |
293
296
  | ----------------- | ------- | ------------------------------------------------------------------------------------------------------ |
@@ -304,7 +307,7 @@ When to suggest: user asks "summarize the milestone", "milestone retro", "give m
304
307
 
305
308
  ### `lumo milestone changelog <identifier> [--project <ref>] [--json]` — structured changelog (LUM-649)
306
309
 
307
- Assembles a **structured changelog** from the milestone's **DONE tasks + merged PRs + frozen claimSnapshot**. Pure deterministic assembly — **no LLM**, and (unlike `summary`) **no COMPLETED gate**: it runs on a milestone of any status, so an ACTIVE milestone reports "what has shipped so far". Only DONE tasks ever appear. `<identifier>` accepts a milestone name or UUID; `--project <ref>` is required when the identifier is a name and the workspace has >1 project.
310
+ Assembles a **structured changelog** from the milestone's **DONE tasks + merged PRs + frozen claimSnapshot**. Pure deterministic assembly — **no LLM**, and (unlike `summary`) **no completion gate**: it runs on a milestone in any state, so an active milestone reports "what has shipped so far". Only DONE tasks ever appear. `<identifier>` accepts a milestone name or UUID; `--project <ref>` is required when the identifier is a name and the workspace has >1 project.
308
311
 
309
312
  Honest-reporting contract (why this is more than a task list):
310
313
 
@@ -145,9 +145,10 @@ what's unmet and why (the exact failure tails), and how many rounds are left.
145
145
  - **Next actions** — the unmet criteria (latest verdict is not a pass: failed or never verified, HUMAN ones included) followed by any **undispositioned PR security findings** (LUM-737) as `• [SECURITY] [SEVERITY] PROVENANCE ruleId — file:line — title (PR #n · blocks DONE | advisory[ — <cause>, downgraded])`. The trailing **downgrade cause** appears only on deterministic `secret:*` rows the scanner lowered to LOW — `placeholder value` (the matched string looks like `…EXAMPLE`, `YOUR_…`, `CHANGEME`…) or `test/fixture/doc path`. Downgrading is **not** suppression: the row is still reported so a human decides, and the cause is what lets you tell a recognised test fixture from a real leak without opening the file. Absent on rows scanned before the cause was recorded, and on any row that was not downgraded — silence means "not downgraded", never "cause unknown". This list IS the plan — recomputed from the event log + the latest scan per linked PR on every read, never maintained separately. The header counts them separately: `Next actions (N unmet · M security findings)`. A finding is **not** a criterion: fix it and push (a fixed fingerprint disappears from the next scan) or a human dispositions it in the web delivery panel — there is no CLI path to clear one, and a `blocks DONE` finding refuses DONE with 409. Empty + rounds recorded = awaiting human adjudication.
146
146
  - **Per-PR scan status** (LUM-735): before the unconfirmed-PR lines, one line per linked PR that has a latest scan (open or closed alike): `PR #945 · scan CLEAN · Secrets: checked · Code scan: checked (2 external findings) · Dependencies: checked (1 dependency finding, 1 already on main) · AI review: checked · Exploit paths: ⚠ incomplete` — stage segments only for keys present, in secrets/external/supplyChain/judge/hunt order. **The stage keys are internal; what prints is their display label** (LUM-763, `shared/src/security-scan.ts`, shared with the GitHub PR summary so the two cannot drift): `secrets`→Secrets, `external`→Code scan, `supplyChain`→Dependencies, `judge`→AI review, `hunt`→Exploit paths; states render as `RAN`→checked, `SKIPPED`→skipped, `NOT_CONFIGURED`→off, `PENDING`→scanning, `PARTIAL`→⚠ incomplete, `SUPERSEDED`→⚠ superseded, `FAILED`→✗ failed. "checked" means the layer completed, **not** that it found nothing — findings are counted in their own segments and in the next-actions list (`hunt` = the L3 vulnerability hunt, LUM-739: absent when `LUMO_SECURITY_HUNT=off`, `SKIPPED` when no trigger fired, `PENDING` while the workflow runs, `RAN` / `PARTIAL` (budget hit, a planned task that never finished, or more surviving candidates than the falsifier cap) / `FAILED`; its findings are `LLM_JUDGE` advisory, never `blocks DONE`); `(N external findings)` (singular at 1) decorates only the Code scan segment, and only when N > 0; `(N dependency findings[, M already on main])` (LUM-738; singular at 1) decorates only the Dependencies segment, only when N > 0, and prints bare `Dependencies: <state>` against an older server without the counts; ` · partial` appended when the scan is partial; and, when `stages.external === 'FAILED'` and a **scrubbed scanner reason** was recorded (the `error` column carries the `external: ` provenance prefix), an appended ` — <reason without the prefix, tail 200 chars>` — an unprefixed `error` (stage A's own raw crash text) is never printed here, same P8 rule as the web panel and the PR summary (LUM-756). Fed by the additive `securityFindings.scans` array; omitted entirely (no lines) against an older server that doesn't send it.
147
147
  - **Hunt coverage line** (LUM-762): indented under its scan line whenever the server sends the L3 hunt's per-category audit — `Exploit paths 2/3 done · t1 idor 41.2s done · t2 injection 72.0s done (2 attempts) · t3 race skipped`. `<n>/N done` is how many planned tasks finished (where N is the number of tasks the planning step selected for this scan, typically 0–3). Rows persisted before LUM-758 lack a planned-task count and fall back to the historical 6-category denominator (reading as `/6` instead). Each segment is `[taskId ]<category> <elapsed>s <stop>` — the `taskId` prefix (`t1`, `t2`, …) appears only on current rows where the task graph is present (post-LUM-758), and is omitted on pre-task-graph audit rows, so they render exactly as they always did. Elapsed is summed over the task's attempts; `(N attempts)` when the workflow retried it; no elapsed for a `skipped` task (the scan's budget ran out before its turn). Stop values: `done` · `steps` · `budget` · `timeout` · `error` · `skipped`. Headed by the same display label as the layer's own segment (LUM-763), so the detail line and the line above it never name the layer two different ways. This is the only surface that reads `huntAudit`, so it is where you answer "how long does a healthy task take" and "which tasks never ran" without paging through deploy logs. Absent on an older server and on scans with no usable audit (pre-LUM-739 rows, or a hunt that failed before its first batch) — no line, never a guess.
148
+ - **Queue exit** (LUM-775): a merged / closed PR's line ends with ` · N left the queue (a resolved on main, b merged unreviewed, c PR closed)` when any undispositioned row has left the queue — derived at read time, never stored. `resolved on main` = an external-tool finding whose fingerprint is absent from a default-branch baseline that can testify (landed, whole, requested after the merge, ran that tool); `merged unreviewed` = an advisory row whose PR merged without a decision (a policy-blocking row never exits this way — it stays a next action and keeps blocking DONE until a human decides, or until a fresh baseline shows it gone); `PR closed` = closed without merging, nothing landed (reopening the PR puts the rows back). Exited rows are not next actions, are not counted in `openFindings`, and show muted with the reason in the web panel. Absent on an older server. The workspace-wide read-out with per-row reasons is `scripts/analysis/lum775-finding-queue/report.ts`.
148
149
  - **Persisting findings** (LUM-738): a finding whose fingerprint is already on the repository's default-branch baseline (`persisting`) is not this PR's — it never appears as a next action, is not counted in `openFindings`, and shows muted (_already on the default branch_) in the web panel. Dependency findings (`kind=DEPENDENCY`, from osv-scanner) are advisory: they never block DONE.
149
150
  - **Fails closed:** `⚠ Security-scan check failed — could not confirm …` when the scan read errored, and one `⚠ PR #n: … — could not confirm it is clean.` line per open PR whose latest scan is missing / FAILED / still running. Silence means a successful read with nothing open, never a failed check.
150
- - **Open boundary crossings** — a trailing safety block when the task has ≥1 OPEN (undispositioned) forbidden-action crossing: a count, then one line per crossing `• [SEVERITY] CATEGORY — <clipped detail>` (highest-severity first), each followed by a read-only **attribution** line `↳ by model=<m> · agent=<type>[/branch] · session=<8-char prefix>` (who/what crossed; any dimension that couldn't be resolved server-side prints `unknown`, never a fabricated value), then a pointer to `lumo crossing disposition` and the web acceptance panel. Silent when there are none, so it never overshadows the criteria.
151
+ - **Open boundary crossings** — a trailing safety block when the task has ≥1 OPEN (undispositioned) forbidden-action crossing: a count split the way the server DONE gate counts it — `(N undispositioned: B blocking DONE, A advisory / non-blocking)` (LUM-771) — then one line per crossing, blocking rows first as `• [SEVERITY] CATEGORY — <clipped detail>` (highest-severity first) and non-blocking rows as `◦ [SEVERITY] CATEGORY (advisory — does not block DONE)` / `(below the workspace gate — does not block DONE)`; only the `•` rows need a ruling before DONE, each followed by a read-only **attribution** line `↳ by model=<m> · agent=<type>[/branch] · session=<8-char prefix>` (who/what crossed; any dimension that couldn't be resolved server-side prints `unknown`, never a fabricated value), then a pointer to `lumo crossing disposition` and the web acceptance panel. Silent when there are none, so it never overshadows the criteria.
151
152
  - **Read-only awareness** — this block surfaces crossings detected elsewhere and clears nothing itself. The ruling is the user's: `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope you relay, LUM-769 — see below) or the web panel. You never self-approve.
152
153
  - **The check fails closed:** if the crossings read itself errors (network / server / parse), the block prints `⚠ Boundary-crossing check failed (network/server error) — could not confirm whether any are undispositioned` instead of staying silent. Silence means a successful read with zero open crossings, never a failed check — a hiccup can no longer masquerade as "all clear".
153
154
 
@@ -83,7 +83,7 @@ function formatEntry(entry) {
83
83
  }
84
84
  function formatMilestoneChangelog(cl) {
85
85
  const lines = [
86
- `# Changelog — ${(0, sanitize_1.sanitizeField)(cl.milestone.name)} (${cl.milestone.status})`,
86
+ `# Changelog — ${(0, sanitize_1.sanitizeField)(cl.milestone.name)} (${cl.milestone.state})`,
87
87
  ];
88
88
  const window = formatWindow(cl.milestone.startDate, cl.milestone.targetDate);
89
89
  if (window)
@@ -19,23 +19,30 @@ function formatDate(iso) {
19
19
  return '-';
20
20
  return iso.slice(0, 10);
21
21
  }
22
+ /** LUM-714: the milestone's state, folded from its two timestamps. */
23
+ function rowState(r) {
24
+ if (r.archivedAt)
25
+ return 'ARCHIVED';
26
+ return r.completedAt ? 'COMPLETED' : 'ACTIVE';
27
+ }
22
28
  /**
23
29
  * Render milestones as fixed-width rows:
24
- * <STATUS> <HEALTH> <target-date or -> <name>
30
+ * <STATE> <HEALTH> <target-date or -> <name>
25
31
  *
32
+ * STATE is ACTIVE / COMPLETED / ARCHIVED (LUM-714) — derived, never stored.
26
33
  * HEALTH is the target-date risk light (ON-TRACK / AT-RISK / OVERDUE), or `-`
27
- * when no light applies (terminal status or no target date).
34
+ * when no light applies (completed, archived, or no target date).
28
35
  *
29
36
  * Sorted server-side by targetDate asc nulls last, createdAt asc.
30
37
  */
31
38
  function formatMilestoneList(rows) {
32
39
  if (rows.length === 0)
33
40
  return 'No milestones.';
34
- const statusW = Math.max(...rows.map(r => r.status.length));
41
+ const stateW = Math.max(...rows.map(r => rowState(r).length));
35
42
  const healthW = Math.max(...rows.map(r => formatHealth(r.health).length));
36
43
  const dateW = Math.max(...rows.map(r => formatDate(r.targetDate).length));
37
44
  return rows
38
- .map(r => `${r.status.padEnd(statusW)} ${formatHealth(r.health).padEnd(healthW)} ${formatDate(r.targetDate).padEnd(dateW)} ${r.archivedAt ? `${(0, sanitize_1.sanitizeField)(r.name)} (archived)` : (0, sanitize_1.sanitizeField)(r.name)}`)
45
+ .map(r => `${rowState(r).padEnd(stateW)} ${formatHealth(r.health).padEnd(healthW)} ${formatDate(r.targetDate).padEnd(dateW)} ${(0, sanitize_1.sanitizeField)(r.name)}`)
39
46
  .join('\n');
40
47
  }
41
48
  async function milestoneList(options) {
@@ -65,15 +65,25 @@ function formatMilestoneShow(m, tasks) {
65
65
  m.taskCounts.IN_PROGRESS +
66
66
  m.taskCounts.IN_REVIEW +
67
67
  m.taskCounts.DONE;
68
- const statusLine = m.staleness?.statusDrift
69
- ? `Status: ${m.status} (staletask progress indicates ${(0, sanitize_1.sanitizeField)(m.staleness.statusDrift)}; auto-updating)`
70
- : `Status: ${m.status}`;
68
+ // LUM-714: state is derived, so the line reports what the two timestamps
69
+ // sayand, when the tasks disagree with the stored stamp, what the rollup
70
+ // is about to do about it.
71
+ const state = m.archivedAt
72
+ ? 'archived'
73
+ : m.completedAt
74
+ ? 'completed'
75
+ : 'active';
76
+ const drift = m.staleness?.completionDrift;
77
+ const stateLine = drift
78
+ ? `State: ${state} (stale — task progress indicates ${drift === 'complete' ? 'completed' : 'active'}; auto-updating)`
79
+ : `State: ${state}`;
71
80
  const targetLine = m.staleness?.datesMissing
72
81
  ? `Target: ${fmtDate(m.targetDate)} (no target date — schedule health unavailable)`
73
82
  : `Target: ${fmtDate(m.targetDate)}`;
74
83
  const lines = [
75
84
  `Milestone: ${(0, sanitize_1.sanitizeField)(m.name)}`,
76
- statusLine,
85
+ stateLine,
86
+ `Completed: ${m.completedAt ? m.completedAt.slice(0, 10) : 'no'}`,
77
87
  `Archived: ${m.archivedAt ? m.archivedAt.slice(0, 10) : 'no'}`,
78
88
  `Health: ${fmtHealth(m.health)}`,
79
89
  `Start: ${fmtDate(m.startDate)}`,
@@ -156,7 +166,7 @@ async function milestoneShow(identifier, opts) {
156
166
  process.stdout.write(formatMilestoneShow({
157
167
  id: milestone.id,
158
168
  name: milestone.name,
159
- status: milestone.status,
169
+ completedAt: milestone.completedAt,
160
170
  startDate: milestone.startDate,
161
171
  targetDate: milestone.targetDate,
162
172
  archivedAt: milestone.archivedAt,
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeMilestoneStatus = normalizeMilestoneStatus;
4
3
  exports.normalizeTokenBudget = normalizeTokenBudget;
5
4
  exports.buildMilestoneUpdatePayload = buildMilestoneUpdatePayload;
6
5
  exports.formatMilestoneUpdateSummary = formatMilestoneUpdateSummary;
@@ -9,20 +8,6 @@ const config_1 = require("../lib/config");
9
8
  const api_1 = require("../lib/api");
10
9
  const resolve_1 = require("../lib/resolve");
11
10
  const sanitize_1 = require("../lib/sanitize");
12
- const ALLOWED_STATUSES = [
13
- 'PLANNED',
14
- 'ACTIVE',
15
- 'COMPLETED',
16
- 'CANCELLED',
17
- ];
18
- function normalizeMilestoneStatus(value) {
19
- if (!value)
20
- return null;
21
- const upper = value.toUpperCase().replace(/-/g, '_');
22
- return ALLOWED_STATUSES.includes(upper)
23
- ? upper
24
- : null;
25
- }
26
11
  /**
27
12
  * LUM-644: '' clears the budget (→ null, the nullable-flag convention);
28
13
  * otherwise the value must be a positive integer within Postgres INT range.
@@ -49,10 +34,6 @@ function buildMilestoneUpdatePayload(opts) {
49
34
  payload.description = opts.description === '' ? null : opts.description;
50
35
  flagsGiven.push('--description');
51
36
  }
52
- if (opts.status !== undefined) {
53
- payload.status = opts.status;
54
- flagsGiven.push('--status');
55
- }
56
37
  if (opts.start !== undefined) {
57
38
  payload.startDate = opts.start === '' ? null : opts.start;
58
39
  flagsGiven.push('--start');
@@ -85,9 +66,6 @@ function formatMilestoneUpdateSummary(before, after) {
85
66
  if (after.description !== undefined) {
86
67
  changes.push(after.description === null ? 'description → ∅' : 'description updated');
87
68
  }
88
- if (after.status !== undefined && after.status !== before.status) {
89
- changes.push(`status ${before.status} → ${after.status}`);
90
- }
91
69
  if (after.startDate !== undefined) {
92
70
  changes.push(`start → ${fmtDate(after.startDate)}`);
93
71
  }
@@ -102,28 +80,15 @@ function formatMilestoneUpdateSummary(before, after) {
102
80
  return `Updated milestone "${beforeName}": ${changes.join(', ')}`;
103
81
  }
104
82
  async function milestoneUpdate(identifier, opts) {
105
- // Validate status flag eagerly.
106
- let normalizedStatus;
107
- if (opts.status !== undefined) {
108
- const n = normalizeMilestoneStatus(opts.status);
109
- if (!n) {
110
- console.error(`Error: invalid status "${opts.status}". Allowed: planned, active, completed, cancelled`);
111
- return 1;
112
- }
113
- normalizedStatus = n;
114
- }
115
83
  // Validate token budget eagerly (LUM-644): positive integer, or '' to clear.
116
84
  if (opts.tokenBudget !== undefined &&
117
85
  normalizeTokenBudget(opts.tokenBudget) === undefined) {
118
86
  console.error(`Error: invalid token budget "${opts.tokenBudget}". Provide a positive integer (e.g. 5000000), or "" to clear.`);
119
87
  return 1;
120
88
  }
121
- const { payload, flagsGiven } = buildMilestoneUpdatePayload({
122
- ...opts,
123
- ...(normalizedStatus !== undefined && { status: normalizedStatus }),
124
- });
89
+ const { payload, flagsGiven } = buildMilestoneUpdatePayload(opts);
125
90
  if (flagsGiven.length === 0) {
126
- console.error('Error: provide at least one field to update (--name, --description, --status, --start, --target, --token-budget)');
91
+ console.error('Error: provide at least one field to update (--name, --description, --start, --target, --token-budget)');
127
92
  return 1;
128
93
  }
129
94
  const creds = (0, config_1.readCredentials)();
@@ -64,7 +64,7 @@ function formatTaskContextMarkdown(data, now) {
64
64
  const target = data.task.milestone.targetDate
65
65
  ? `, target ${data.task.milestone.targetDate.slice(0, 10)}`
66
66
  : '';
67
- lines.push(`**Milestone**: ${(0, sanitize_1.sanitizeField)(data.task.milestone.name)} (${data.task.milestone.status}${target})`);
67
+ lines.push(`**Milestone**: ${(0, sanitize_1.sanitizeField)(data.task.milestone.name)} (${data.task.milestone.state}${target})`);
68
68
  const milestoneGoal = data.task.milestone.description;
69
69
  if (milestoneGoal && milestoneGoal.trim().length > 0) {
70
70
  lines.push(`**Milestone goal**: ${(0, sanitize_1.sanitizeField)(milestoneGoal)}`);
@@ -65,7 +65,9 @@ const SCAN_STAGE_ORDER = security_scan_1.SCAN_STAGE_KEYS;
65
65
  /** LUM-735 — one line per linked PR's latest scan: `PR #945 · scan CLEAN ·
66
66
  * Secrets: checked · Code scan: checked (2 external findings) ·
67
67
  * Dependencies: checked (1 dependency finding, 1 already on main) ·
68
- * AI review: checked · Exploit paths: checked`.
68
+ * AI review: checked · Exploit paths: checked`, plus, on a merged / closed
69
+ * PR, ` · 3 left the queue (2 resolved on main, 1 merged unreviewed)`
70
+ * (LUM-775).
69
71
  * Stage segments only for keys present, in
70
72
  * secrets/external/supplyChain/judge/hunt order (LUM-739 added the hunt
71
73
  * layer); the `(N external findings)` count only decorates the Code scan
@@ -101,6 +103,12 @@ function formatScanSummaryLine(s) {
101
103
  line += ` · ${segments.join(' · ')}`;
102
104
  if (s.partial)
103
105
  line += ' · partial';
106
+ // LUM-775: what left the queue because the PR merged / closed, and why —
107
+ // so a merged PR's line explains where its findings went instead of
108
+ // silently listing fewer next actions than the scan found.
109
+ const exits = (0, security_scan_1.formatQueueExits)(s.queueExits);
110
+ if (exits)
111
+ line += ` · ${exits}`;
104
112
  // LUM-756 (P8): `error` has two writers — only the prefixed, scrubbed
105
113
  // scanner reason may be printed, and only the text after the prefix. Stage
106
114
  // A's raw crash text carries no prefix and is never shown here (same rule
@@ -611,12 +619,21 @@ function pushOpenCrossings(lines, extras) {
611
619
  const open = result.crossings;
612
620
  if (open.length === 0)
613
621
  return;
622
+ // LUM-771: say which rows the server DONE gate actually counts — advisory
623
+ // rows (and, with the gate off, non-HIGH rows) are awareness, not blockers.
624
+ const blocking = open.filter(c => c.blocking);
625
+ const rest = open.filter(c => !c.blocking);
614
626
  lines.push('');
615
- lines.push(`⚠ Open boundary crossings (${open.length} undispositioned):`);
627
+ lines.push(`⚠ Open boundary crossings (${open.length} undispositioned: ${blocking.length} blocking DONE, ${rest.length} advisory / non-blocking):`);
616
628
  for (const c of open) {
617
629
  const detail = oneLineDetail(c.detail);
618
630
  const tail = detail ? ` — ${detail}` : '';
619
- lines.push(` • [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${tail}`);
631
+ const gate = c.blocking
632
+ ? ''
633
+ : c.advisory
634
+ ? ' (advisory — does not block DONE)'
635
+ : ' (below the workspace gate — does not block DONE)';
636
+ lines.push(` ${c.blocking ? '•' : '◦'} [${c.severity}] ${(0, sanitize_1.sanitizeField)(c.category)}${gate}${tail}`);
620
637
  lines.push(` ${formatAttribution(c.attribution)}`);
621
638
  }
622
639
  lines.push(" Disposition is the user's call: relay each crossing, then `lumo crossing disposition <id> --false-positive | --confirmed` (exit-4 envelope; never self-approve), or the web acceptance panel:");
@@ -391,7 +391,7 @@ async function runTaskUpdate(identifier, opts, collected) {
391
391
  if (res.status === 409 &&
392
392
  status === 'DONE' &&
393
393
  (0, blocked_error_1.isDoneGateRefusal)(serverMsg)) {
394
- const { blockers, unconfirmed } = await (0, blocked_error_1.collectDoneBlockers)({
394
+ const { blockers, unconfirmed, nonBlocking } = await (0, blocked_error_1.collectDoneBlockers)({
395
395
  base,
396
396
  token: creds.token,
397
397
  identifier,
@@ -401,6 +401,7 @@ async function runTaskUpdate(identifier, opts, collected) {
401
401
  message: serverMsg,
402
402
  blockers,
403
403
  unconfirmed,
404
+ nonBlocking,
404
405
  apiUrl,
405
406
  workspaceSlug: creds.workspaceSlug ?? '',
406
407
  }));
@@ -756,11 +756,10 @@ milestoneCmd
756
756
  .action(wrap((identifier, options) => (0, milestone_show_1.milestoneShow)(identifier, options)));
757
757
  milestoneCmd
758
758
  .command('update <identifier>')
759
- .description('Update a milestone. Provide at least one of --name, --description, --status, --start, --target, --token-budget. Use "" to clear nullable fields.')
759
+ .description('Update a milestone. Provide at least one of --name, --description, --start, --target, --token-budget. Use "" to clear nullable fields. Completion is derived from task completion (LUM-714) — there is no status flag.')
760
760
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
761
761
  .option('-n, --name <text>', 'New name')
762
762
  .option('-d, --description <text>', 'New description (empty string to clear)')
763
- .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
764
763
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
765
764
  .option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
766
765
  .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.')
@@ -46,6 +46,7 @@ async function collectDoneBlockers(args) {
46
46
  const { base, token, identifier } = args;
47
47
  const blockers = [];
48
48
  const unconfirmed = [];
49
+ const nonBlocking = [];
49
50
  const headers = { Authorization: `Bearer ${token}` };
50
51
  // Send-backs + blocking security findings: the acceptance-status read model.
51
52
  try {
@@ -91,17 +92,30 @@ async function collectDoneBlockers(args) {
91
92
  unconfirmed.push(`boundary-crossings read failed: ${crossings.reason}`);
92
93
  }
93
94
  else {
95
+ // LUM-771: mirror the server gate — an open row is a blocker only when
96
+ // the gate counts it; the rest are surfaced separately, never as blockers.
94
97
  for (const c of crossings.crossings) {
95
- blockers.push({
96
- kind: 'BOUNDARY_CROSSING',
97
- id: c.id,
98
- severity: c.severity,
99
- category: c.category,
100
- detail: c.detail,
101
- });
98
+ if (c.blocking) {
99
+ blockers.push({
100
+ kind: 'BOUNDARY_CROSSING',
101
+ id: c.id,
102
+ severity: c.severity,
103
+ category: c.category,
104
+ detail: c.detail,
105
+ });
106
+ }
107
+ else {
108
+ nonBlocking.push({
109
+ id: c.id,
110
+ severity: c.severity,
111
+ category: c.category,
112
+ detail: c.detail,
113
+ advisory: c.advisory,
114
+ });
115
+ }
102
116
  }
103
117
  }
104
- return { blockers, unconfirmed };
118
+ return { blockers, unconfirmed, nonBlocking };
105
119
  }
106
120
  /**
107
121
  * One line per blocker kind present. Every line names a human-side path, the
@@ -138,6 +152,7 @@ function buildBlockedError(args) {
138
152
  task: args.identifier,
139
153
  message: args.message,
140
154
  blockers: args.blockers,
155
+ nonBlocking: args.nonBlocking ?? [],
141
156
  unconfirmed: args.unconfirmed,
142
157
  remediation: buildRemediation(args.blockers, args.unconfirmed, {
143
158
  identifier: args.identifier,
@@ -166,6 +181,13 @@ function renderBlocked(err, isTTY) {
166
181
  '',
167
182
  err.blockers.length > 0 ? 'Blockers:' : 'Blockers: (none listed)',
168
183
  ...err.blockers.map(b => ` • ${(0, sanitize_1.sanitizeField)(blockerLine(b))}`),
184
+ ...(err.nonBlocking.length > 0
185
+ ? [
186
+ '',
187
+ 'Also open, not blocking DONE (advisory / below the workspace gate):',
188
+ ...err.nonBlocking.map(c => ` ◦ [${c.severity}${c.advisory ? ' advisory' : ''}] ${(0, sanitize_1.sanitizeField)(c.category)} ${c.id}: ${(0, sanitize_1.sanitizeField)(c.detail)}`),
189
+ ]
190
+ : []),
169
191
  ...(err.unconfirmed.length > 0
170
192
  ? ['', ...err.unconfirmed.map(u => ` ⚠ ${u}`)]
171
193
  : []),
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectDeletedTestFiles = collectDeletedTestFiles;
4
+ exports.looksLikeDeleteCommand = looksLikeDeleteCommand;
5
+ const child_process_1 = require("child_process");
6
+ const test_file_path_1 = require("../../../shared/src/test-file-path");
7
+ /**
8
+ * LUM-779 — the local half of the TEST_FILE_DELETED net-deletion verdict.
9
+ *
10
+ * The server's matcher only sees command TEXT, and text lies: a restore from
11
+ * HEAD, a `cp` back from a .bak, or an `rm` of a scratch probe the agent wrote
12
+ * seconds earlier all look like "deleted a test file" and none of them did.
13
+ * What settles it is repo state — is a tracked test file missing now — and
14
+ * only the hook, running on the agent's machine, can ask git that question.
15
+ *
16
+ * `collectDeletedTestFiles` answers with the tracked test files that are gone:
17
+ * - working tree + index vs HEAD (`git diff HEAD --diff-filter=D`) — a plain
18
+ * `rm` or a `git rm` of a tracked file, not yet committed
19
+ * - commits on top of the base branch (`git log --diff-filter=D base..HEAD`)
20
+ * — an `rm … && git commit` chained in one command would otherwise be
21
+ * invisible to the first probe
22
+ * Filtered by the shared test-path pattern, sorted, de-duplicated.
23
+ *
24
+ * `null` means the probe could not answer (not a repository, git missing, a
25
+ * timeout) — the caller omits the field and the server keeps its fail-closed
26
+ * blocking behaviour. `[]` is a verdict: the probe ran and nothing is missing.
27
+ */
28
+ /** Base-branch candidates, in order. The first that resolves wins. */
29
+ const BASE_REF_CANDIDATES = [
30
+ 'refs/remotes/origin/HEAD',
31
+ 'refs/remotes/origin/main',
32
+ 'refs/remotes/origin/master',
33
+ 'refs/remotes/origin/develop',
34
+ ];
35
+ const GIT_TIMEOUT_MS = 2000;
36
+ function runGit(args, cwd) {
37
+ try {
38
+ const r = (0, child_process_1.spawnSync)('git', args, {
39
+ cwd,
40
+ encoding: 'utf8',
41
+ timeout: GIT_TIMEOUT_MS,
42
+ stdio: ['ignore', 'pipe', 'ignore'],
43
+ });
44
+ if (r.error || r.status !== 0)
45
+ return { ok: false, stdout: '' };
46
+ return { ok: true, stdout: r.stdout ?? '' };
47
+ }
48
+ catch {
49
+ return { ok: false, stdout: '' };
50
+ }
51
+ }
52
+ /** NUL-separated path list → paths (empty entries dropped). */
53
+ function splitPaths(stdoutZ) {
54
+ return stdoutZ.split('\0').filter(p => p.length > 0);
55
+ }
56
+ function resolveBaseRef(cwd) {
57
+ for (const ref of BASE_REF_CANDIDATES) {
58
+ if (runGit(['rev-parse', '--verify', '-q', ref], cwd).ok)
59
+ return ref;
60
+ }
61
+ return null;
62
+ }
63
+ function collectDeletedTestFiles(cwd) {
64
+ // Working tree + index vs HEAD. Also the "is this a repo with a HEAD"
65
+ // probe: a non-repository, or git itself missing, fails here → null.
66
+ const uncommitted = runGit(['diff', 'HEAD', '--diff-filter=D', '--name-only', '-z'], cwd);
67
+ if (!uncommitted.ok)
68
+ return null;
69
+ const missing = new Set(splitPaths(uncommitted.stdout));
70
+ // Deletions already committed on top of the base branch. Best-effort: no
71
+ // resolvable base (a repo with no remote) just skips this half.
72
+ const base = resolveBaseRef(cwd);
73
+ if (base) {
74
+ const committed = runGit([
75
+ 'log',
76
+ '--diff-filter=D',
77
+ '--name-only',
78
+ '--format=',
79
+ '-z',
80
+ `${base}..HEAD`,
81
+ ], cwd);
82
+ if (committed.ok) {
83
+ for (const p of splitPaths(committed.stdout))
84
+ missing.add(p);
85
+ }
86
+ }
87
+ return [...missing].filter(test_file_path_1.isTestFilePath).sort();
88
+ }
89
+ /**
90
+ * Cheap pre-filter deciding whether the probe is worth running at all: an
91
+ * `rm` / `git rm` token anywhere in the command, including inside a `bash -c`
92
+ * wrapper string. Deliberately a SUPERSET of the server's matcher — a probe
93
+ * that runs for an `rm` of a non-test file costs two git calls and nothing
94
+ * else, whereas a probe that is skipped for a command the server does flag
95
+ * leaves the server fail-closed on a possible false positive.
96
+ */
97
+ function looksLikeDeleteCommand(command) {
98
+ return /(?:^|[\s(;&|"'`])(?:rm|git\s+rm)\b/.test(command);
99
+ }
@@ -4,6 +4,7 @@ exports.formatHookStdoutLines = formatHookStdoutLines;
4
4
  exports.formatSuggestLine = formatSuggestLine;
5
5
  exports.resolveSessionStartStdout = resolveSessionStartStdout;
6
6
  exports.augmentBodyWithUsage = augmentBodyWithUsage;
7
+ exports.augmentBodyWithDeletedTestFiles = augmentBodyWithDeletedTestFiles;
7
8
  exports.runHook = runHook;
8
9
  exports.runHookWithBody = runHookWithBody;
9
10
  const config_1 = require("./config");
@@ -16,6 +17,7 @@ const transcript_usage_1 = require("./transcript-usage");
16
17
  const resolve_project_1 = require("./resolve-project");
17
18
  const resolve_bound_task_1 = require("./resolve-bound-task");
18
19
  const memory_auto_1 = require("./memory-auto");
20
+ const git_deleted_test_files_1 = require("./git-deleted-test-files");
19
21
  /**
20
22
  * Hard timeout for the hook POST. On timeout the request is aborted,
21
23
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -261,6 +263,54 @@ function augmentBodyWithUsage(path, body) {
261
263
  _lumo_usage: { ...usage.total, byModel: usage.byModel },
262
264
  });
263
265
  }
266
+ /**
267
+ * LUM-779: for a post-tool-use Bash event whose command carries an `rm` /
268
+ * `git rm`, ask local git which tracked test files are actually missing now
269
+ * and fold the answer into the body under `_lumo_deleted_test_files`. The
270
+ * server judges TEST_FILE_DELETED by this list — not by the command text —
271
+ * so a restore or an `rm` of an untracked scratch probe is recorded advisory
272
+ * while a real deletion of a tracked test still blocks.
273
+ *
274
+ * The three wire states matter: a non-empty list (blocking), an EMPTY list
275
+ * (probe ran, nothing missing → advisory), and NO field (probe skipped or
276
+ * git could not answer → the server fails closed). So the field is only ever
277
+ * attached when the probe returned an array, and a probe that throws leaves
278
+ * the body untouched. Never blocks or fails the hook.
279
+ */
280
+ function augmentBodyWithDeletedTestFiles(path, body, probe = git_deleted_test_files_1.collectDeletedTestFiles) {
281
+ if (path !== 'post-tool-use')
282
+ return body;
283
+ let parsed;
284
+ try {
285
+ parsed = JSON.parse(body || '{}');
286
+ }
287
+ catch {
288
+ return body;
289
+ }
290
+ if (!parsed || typeof parsed !== 'object')
291
+ return body;
292
+ if (parsed['tool_name'] !== 'Bash')
293
+ return body;
294
+ const input = parsed['tool_input'];
295
+ const command = input && typeof input === 'object'
296
+ ? input['command']
297
+ : undefined;
298
+ if (typeof command !== 'string' || !(0, git_deleted_test_files_1.looksLikeDeleteCommand)(command))
299
+ return body;
300
+ const cwd = typeof parsed['cwd'] === 'string' && parsed['cwd']
301
+ ? parsed['cwd']
302
+ : process.cwd();
303
+ let deleted;
304
+ try {
305
+ deleted = probe(cwd);
306
+ }
307
+ catch {
308
+ return body;
309
+ }
310
+ if (deleted === null)
311
+ return body;
312
+ return JSON.stringify({ ...parsed, _lumo_deleted_test_files: deleted });
313
+ }
264
314
  /**
265
315
  * POST the hook body to /api/hooks/<path> with a short timeout. All errors
266
316
  * — credential missing, network failure, timeout, non-2xx — are routed to
@@ -309,7 +359,8 @@ async function runHookWithBody(path, body, agentToken) {
309
359
  const agentEnum = agentToken ? (0, agent_1.normalizeAgent)(agentToken) : null;
310
360
  if (agentEnum)
311
361
  headers['X-Lumo-Agent'] = agentEnum;
312
- const outgoingBody = augmentBodyWithUsage(path, body) || '{}';
362
+ const outgoingBody = augmentBodyWithDeletedTestFiles(path, augmentBodyWithUsage(path, body)) ||
363
+ '{}';
313
364
  try {
314
365
  const res = await fetch(url, {
315
366
  method: 'POST',
@@ -1,8 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.blocksDone = blocksDone;
3
4
  exports.fetchOpenCrossings = fetchOpenCrossings;
4
5
  exports.dispositionUrl = dispositionUrl;
5
6
  const api_1 = require("./api");
7
+ /**
8
+ * LUM-771: the server's DONE-gate predicate for one open crossing (mirrors
9
+ * `assertNoUnacknowledgedBoundaryCrossings` and the panel): an advisory row
10
+ * never blocks; with the workspace gate disabled only HIGH rows block.
11
+ */
12
+ function blocksDone(c, gateEnabled) {
13
+ return !c.advisory && (gateEnabled || c.severity === 'HIGH');
14
+ }
6
15
  const SEVERITY_RANK = {
7
16
  HIGH: 3,
8
17
  MEDIUM: 2,
@@ -62,17 +71,28 @@ async function fetchOpenCrossings(apiUrl, token, taskIdentifier) {
62
71
  return { status: 'error', reason: 'invalid response body' };
63
72
  }
64
73
  const rows = Array.isArray(data.crossings) ? data.crossings : [];
74
+ // LUM-771: only an explicit `false` disables the gate — absent (older server)
75
+ // or malformed reads as enabled, so a hiccup can never hide a blocker.
76
+ const gateEnabled = data.gateEnabled !== false;
65
77
  const crossings = rows
66
78
  .filter(c => c.disposition == null)
67
- .map(c => ({
68
- id: c.id,
69
- category: c.category,
70
- severity: normalizeSeverity(c.severity),
71
- detail: c.detail,
72
- attribution: normalizeAttribution(c.attribution),
73
- }))
74
- .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
75
- return { status: 'ok', crossings };
79
+ .map(c => {
80
+ const severity = normalizeSeverity(c.severity);
81
+ const advisory = c.advisory === true;
82
+ return {
83
+ id: c.id,
84
+ category: c.category,
85
+ severity,
86
+ detail: c.detail,
87
+ attribution: normalizeAttribution(c.attribution),
88
+ advisory,
89
+ blocking: blocksDone({ advisory, severity }, gateEnabled),
90
+ };
91
+ })
92
+ // Blocking rows first, then by severity — what the human must act on leads.
93
+ .sort((a, b) => Number(b.blocking) - Number(a.blocking) ||
94
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
95
+ return { status: 'ok', crossings, gateEnabled };
76
96
  }
77
97
  /**
78
98
  * The web deep link where a human dispositions crossings in the panel — the
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SECRET_DOWNGRADE_LABELS = exports.SCAN_STAGE_STATE_LABELS = exports.SCAN_STAGE_LABELS = exports.SCAN_STAGE_STATES = exports.SCAN_STAGE_KEYS = exports.EXTERNAL_ERROR_PREFIX = void 0;
3
+ exports.QUEUE_EXIT_LABELS = exports.QUEUE_EXIT_REASONS = exports.SECRET_DOWNGRADE_LABELS = exports.SCAN_STAGE_STATE_LABELS = exports.SCAN_STAGE_LABELS = exports.SCAN_STAGE_STATES = exports.SCAN_STAGE_KEYS = exports.EXTERNAL_ERROR_PREFIX = void 0;
4
4
  exports.externalFailureReason = externalFailureReason;
5
5
  exports.scanStageLabel = scanStageLabel;
6
6
  exports.scanStageStateLabel = scanStageStateLabel;
7
7
  exports.secretDowngradeNote = secretDowngradeNote;
8
+ exports.formatQueueExits = formatQueueExits;
8
9
  /**
9
10
  * PR security scan — the one `PrSecurityScan.error` shape that may be shown
10
11
  * as an external-scanner reason (spec P8). Shared by the web panel, the PR
@@ -123,3 +124,57 @@ function secretDowngradeNote(reason) {
123
124
  return '';
124
125
  return ` — ${exports.SECRET_DOWNGRADE_LABELS[reason] ?? reason}, downgraded`;
125
126
  }
127
+ /**
128
+ * LUM-775 — why an undispositioned finding is no longer in the queue.
129
+ *
130
+ * A PR finding's review window is the PR. Once the PR is merged or closed
131
+ * the question changes from "what did this change introduce" to "what is on
132
+ * main now", and only the default-branch baseline (LUM-738) may answer that.
133
+ * The exit is DERIVED at read time from PR state × baseline × policy — never
134
+ * written to the row, so `disposition` stays a human-only column.
135
+ *
136
+ * - `RESOLVED_ON_MAIN`: an external-tool finding whose fingerprint is absent
137
+ * from a baseline that can testify (landed, whole, requested after the
138
+ * merge, ran that tool). The one exit with evidence.
139
+ * - `MERGED_UNREVIEWED`: the window closed without a decision. Advisory rows
140
+ * only — a policy-blocking row stays until a human decides.
141
+ * - `PR_CLOSED`: closed without merging; nothing landed. Reopening the PR
142
+ * puts the row straight back (derived, not stored).
143
+ */
144
+ exports.QUEUE_EXIT_REASONS = [
145
+ 'RESOLVED_ON_MAIN',
146
+ 'MERGED_UNREVIEWED',
147
+ 'PR_CLOSED',
148
+ ];
149
+ /** One wording per exit on every surface (CLI scan line, analysis report). */
150
+ exports.QUEUE_EXIT_LABELS = {
151
+ RESOLVED_ON_MAIN: 'resolved on main',
152
+ MERGED_UNREVIEWED: 'merged unreviewed',
153
+ PR_CLOSED: 'PR closed',
154
+ };
155
+ /**
156
+ * `3 left the queue (2 resolved on main, 1 merged unreviewed)`, or null when
157
+ * nothing left — the caller omits the segment rather than printing a zero.
158
+ * Reasons print in the declared order; an unknown key (a newer server) is
159
+ * counted in the total and printed as itself, never dropped.
160
+ */
161
+ function formatQueueExits(counts) {
162
+ if (!counts)
163
+ return null;
164
+ const parts = [];
165
+ let total = 0;
166
+ const keys = [
167
+ ...exports.QUEUE_EXIT_REASONS.filter(k => k in counts),
168
+ ...Object.keys(counts).filter(k => !exports.QUEUE_EXIT_REASONS.includes(k)),
169
+ ];
170
+ for (const key of keys) {
171
+ const n = counts[key] ?? 0;
172
+ if (n <= 0)
173
+ continue;
174
+ total += n;
175
+ parts.push(`${n} ${exports.QUEUE_EXIT_LABELS[key] ?? key}`);
176
+ }
177
+ if (total === 0)
178
+ return null;
179
+ return `${total} left the queue (${parts.join(', ')})`;
180
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TEST_FILE_TARGET = void 0;
4
+ exports.isTestFilePath = isTestFilePath;
5
+ /**
6
+ * What counts as a test file / test tree for the TEST_FILE_DELETED session
7
+ * guard (LUM-736): `*.test.*`, `*.spec.*`, or anything under a `__tests__/`
8
+ * segment. Single-sourced here (LUM-779) because two sides must agree on it:
9
+ * the server's command matcher (lib/services/boundary-detector.service.ts)
10
+ * and the CLI's local-git probe (cli/src/lib/git-deleted-test-files.ts) that
11
+ * reports which tracked test files are actually missing. A pattern that
12
+ * drifted between the two would let a real deletion slip through as "nothing
13
+ * missing" — so there is exactly one pattern.
14
+ */
15
+ exports.TEST_FILE_TARGET = /(?:\.(?:test|spec)\.|(?:^|\/)__tests__(?:\/|$))/;
16
+ function isTestFilePath(path) {
17
+ return exports.TEST_FILE_TARGET.test(path);
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.60.0",
3
+ "version": "1.62.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",