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