@lumoai/cli 1.48.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.
- package/assets/skill/SKILL.md +15 -3
- package/assets/skill/references/criteria.md +25 -3
- package/assets/skill/references/fidelity.md +32 -0
- package/assets/skill/references/milestones.md +4 -0
- package/assets/skill/references/outcome.md +7 -7
- package/assets/skill/references/sessions.md +30 -2
- package/assets/skill/references/verify.md +12 -11
- package/dist/cli/src/commands/fidelity.js +108 -0
- package/dist/cli/src/commands/milestone-show.js +9 -2
- package/dist/cli/src/commands/outcome.js +11 -7
- package/dist/cli/src/commands/session-attach.js +111 -11
- package/dist/cli/src/commands/session-status.js +8 -0
- package/dist/cli/src/commands/task-criteria-set.js +5 -0
- package/dist/cli/src/index.js +13 -3
- package/dist/shared/src/gate-map.js +133 -0
- package/dist/shared/src/referent-kind.js +156 -3
- package/package.json +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -32,6 +32,7 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
32
32
|
| `verify`, `task status` — machine verification loop, claim-done flow, self-check/resume | [references/verify.md](references/verify.md) |
|
|
33
33
|
| `cost` — per-operation (per-tool) token cost read-out; `task lineage` Top-5 | [references/task-context.md](references/task-context.md) |
|
|
34
34
|
| `outcome show/record/rate` — post-hoc outcome well (falsifier oracle, rejection-only) | [references/outcome.md](references/outcome.md) |
|
|
35
|
+
| `fidelity show` — per-delivery grounding + independence change-pattern read-out | [references/fidelity.md](references/fidelity.md) |
|
|
35
36
|
| `project list`, `milestone*` | [references/milestones.md](references/milestones.md) |
|
|
36
37
|
| `doc create/update/list/move/bind/share/import` (CRUD) | [references/docs.md](references/docs.md) |
|
|
37
38
|
| `doc show --raw/--section`, `doc patch/append/diff/rebuild-source` (editing live docs) | [references/doc-editing.md](references/doc-editing.md) |
|
|
@@ -101,7 +102,16 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
101
102
|
|
|
102
103
|
- `lumo outcome show <task>` — read the post-hoc outcome well: the **falsifier** verdict (`REJECTED` | `INCONCLUSIVE` — never a "pass") plus its backing rejection signals. `INCONCLUSIVE` = no rejection on record, **not** "satisfied".
|
|
103
104
|
- `lumo outcome record <task> --note "<what reality did>" [--kind <kind>]` — record a human-observed post-hoc **rejection** of a delivery (revert / rollback / CI regression / downstream redirect / bypass). Append-only; **there is no "mark satisfied" counterpart** — the well only asserts rejection. `--note` is required (the observed referent).
|
|
104
|
-
- `lumo outcome rate [--min N] [--json]` — workspace **trust × post-hoc-fate** join (LUM-603): per delivery-time
|
|
105
|
+
- `lumo outcome rate [--min N] [--json]` — workspace **trust × post-hoc-fate** join (LUM-603, LUM-606): per delivery-time **verification-friction** bucket (`no-verification` / `single-round` / `multi-round-or-sendback` — two raw counts frozen at delivery, replacing the saturating forecast-confidence axis), the post-hoc REJECTED rate from the well. Honest by construction — thin buckets read `insufficient` (no fabricated rate), the single-round-vs-multi comparison stays `inconclusive` until CIs separate, and the rate is a **lower bound** (no signal = INCONCLUSIVE, never satisfied). Bucket names are value-neutral — direction is decided by the correlation, never welded into the label. Under-sampled today by design — the skeleton produces real strata once the well fills.
|
|
106
|
+
|
|
107
|
+
**Fidelity read-out (mechanical change-pattern)** — see [fidelity.md](references/fidelity.md)
|
|
108
|
+
|
|
109
|
+
- `lumo fidelity show <task> [--json]` — per delivery snapshot (LUM-609), a **composition table, not a single score**: (1) the **grounding** of the frozen contract — grounded (`EXTERNAL_FACT`, a machine-checkable upper bound) / self-confirming (`AGENT_CONSTRUCTED_STATE` + `UNVERIFIED_ASSERTION`) / inconclusive (`PENDING_OUTCOME` + `UNCLASSIFIED`, surfaced not swallowed) + the full effective-kind tally; (2) the **independence** signal — criteria `ADDED`/`UPDATED` at round>0 (after work started) that the delivery rode on = backward-inference suspects. Purely mechanical (timing + op + grounding) — **no direction verdict**. A no-snapshot task is reported explicitly (not a pass). Zero new storage — reads the snapshot + `CRITERION_CHANGED` trail.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
lumo fidelity show LUM-42
|
|
113
|
+
lumo fidelity show LUM-42 --json
|
|
114
|
+
```
|
|
105
115
|
|
|
106
116
|
**Artifacts & Figma** — see [artifacts-figma.md](references/artifacts-figma.md)
|
|
107
117
|
|
|
@@ -147,7 +157,8 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
147
157
|
**Sessions** — see [sessions.md](references/sessions.md)
|
|
148
158
|
|
|
149
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`.
|
|
150
|
-
- `lumo session
|
|
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)
|
|
151
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)
|
|
152
163
|
- Git-suggest at session start (suggests `session attach`, never auto-binds) + Layer-2 project-memory review — see the reference
|
|
153
164
|
|
|
@@ -169,7 +180,7 @@ lumo criteria audit
|
|
|
169
180
|
- `EXTERNAL_FACT` — declared as externally observable (git SHA, CI status, DB row) and the checkpointer invokes real external tooling
|
|
170
181
|
- `AGENT_CONSTRUCTED_STATE` — passes by checking output the agent itself produced
|
|
171
182
|
- `PENDING_OUTCOME` — deferred to post-hoc falsifier
|
|
172
|
-
- `UNVERIFIED_ASSERTION` — declared `EXTERNAL_FACT` but the checkpointer does **not** actually invoke external tooling (git/CI/HTTP); downgraded
|
|
183
|
+
- `UNVERIFIED_ASSERTION` — declared `EXTERNAL_FACT` but the checkpointer does **not** actually invoke external tooling (git/CI/HTTP); auto-downgraded. As of LUM-605 this is an **enforced gate, not just a label**: `lumo task criteria set` warns on it, and `lumo verify` blocks a PASS on it with **422 (no round burned)** until it's re-grounded or honestly re-declared `AGENT_CONSTRUCTED_STATE` (see references/criteria.md "referentKind")
|
|
173
184
|
- `UNCLASSIFIED` — legacy rows predating the `referentKind` field (null in DB)
|
|
174
185
|
|
|
175
186
|
2. **Self-confirming-green headline ratio** — `(AGENT_CONSTRUCTED_STATE + UNVERIFIED_ASSERTION) / classified`. Criteria in these two buckets pass by confirming only the agent's own work, not an independent external state.
|
|
@@ -182,6 +193,7 @@ Don't guess these:
|
|
|
182
193
|
|
|
183
194
|
- No `lumo session start` — binding is `lumo session attach <LUM-N>`
|
|
184
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)
|
|
185
197
|
- No `lumo task delete` — tasks can't be deleted from the CLI (web UI only)
|
|
186
198
|
- No `lumo task artifact edit` — it's `lumo task artifact update`
|
|
187
199
|
- No `lumo auth status` — identity check is `lumo whoami`
|
|
@@ -60,6 +60,21 @@ The 3–7 range is calibrated for typical multi-file tasks. Criterion count must
|
|
|
60
60
|
|
|
61
61
|
### judgeSteps — agent-drafted judging steps for HUMAN criteria
|
|
62
62
|
|
|
63
|
+
**`referentKind` — what the criterion anchors (required, and a real gate as of LUM-605):**
|
|
64
|
+
|
|
65
|
+
Every criterion declares one `referentKind` — the only dimension that decides whether a PASS is independent or self-confirming. Required on every item (the API rejects a missing one with 400):
|
|
66
|
+
|
|
67
|
+
- `EXTERNAL_FACT` — anchored on a fact the agent cannot author: a git SHA / diff, CI status, an HTTP probe, a DB row, `prisma migrate status`. **Only honest when the checkpointer actually invokes external tooling** (`git`/`gh`/`curl`/`psql`/…) AND its output or exit status reaches the verdict (piped into an assertion, captured in `$(…)`, or the terminal command whose exit IS the verdict). A no-op or output-discarded external command (`git --version && cat frozen.txt | grep`) does **not** count.
|
|
68
|
+
- `AGENT_CONSTRUCTED_STATE` — passes by checking output the agent itself produced: **your own jest/vitest/tsc/node/tsx tests, build, type-check**. This is the honest kind for almost every code task's checks. Green here is self-confirming — that's fine, just label it truthfully.
|
|
69
|
+
- `PENDING_OUTCOME` — truth lands post-delivery (a downstream regression, a revert). Has no check today: it **cannot** be MACHINE (the API rejects PENDING_OUTCOME + MACHINE). Draft it HUMAN or leave it out of the encoded contract.
|
|
70
|
+
|
|
71
|
+
**The gate (LUM-605):** declaring `EXTERNAL_FACT` + MACHINE on a checkpointer that doesn't ground external (e.g. `npx jest …`) is the LUM-601 fakeGit shape — it would launder a self-confirming green into a "verified external fact". The system now downgrades it to `UNVERIFIED_ASSERTION` and:
|
|
72
|
+
|
|
73
|
+
- at **`criteria set`** — stores it but returns a non-rejecting ⚠ warning naming the criterion (re-ground or re-declare);
|
|
74
|
+
- at **`lumo verify`** — a PASS on it is **blocked with 422 (no round burned)**; the round does not land and the task does not move to IN_REVIEW until you either re-ground the checkpointer against a real external fact, or honestly re-declare it `AGENT_CONSTRUCTED_STATE`. Both fixes are free (no round consumed).
|
|
75
|
+
|
|
76
|
+
Don't reach for `EXTERNAL_FACT` to look stronger — a jest test is `AGENT_CONSTRUCTED_STATE`, full stop. Reserve `EXTERNAL_FACT` for checks that truly read external state, or use a HUMAN criterion for "a person ran the real system and observed X" (HUMAN is never mechanically downgraded).
|
|
77
|
+
|
|
63
78
|
A HUMAN criterion is judged by a person, not a checkpointer — so don't hand them a bare assertion ("the copy reads naturally") and make them reverse-engineer what to do. Attach **`judgeSteps`**: short, human-readable instructions the adjudication card renders verbatim (light markdown, URLs made clickable). Structured labour is yours; the human just follows the steps.
|
|
64
79
|
|
|
65
80
|
**Shape — 1–3 steps, always in this order:**
|
|
@@ -78,6 +93,7 @@ A HUMAN criterion is judged by a person, not a checkpointer — so don't hand th
|
|
|
78
93
|
{
|
|
79
94
|
"statement": "The acceptance tab reads as plain operational instructions, not agent-voice assertions",
|
|
80
95
|
"verifierType": "HUMAN",
|
|
96
|
+
"referentKind": "AGENT_CONSTRUCTED_STATE",
|
|
81
97
|
"judgeSteps": "Open the task's Acceptance tab (the IN_REVIEW card). For each HUMAN criterion, read its 'How to judge' block. Pass if each one names where to look, what to do, and what counts as pass vs send-back; send back if any reads as a bare assertion with no steps."
|
|
82
98
|
}
|
|
83
99
|
```
|
|
@@ -97,12 +113,14 @@ Two real-repo invariant examples — `prisma/migrations/` files never deleted (t
|
|
|
97
113
|
{
|
|
98
114
|
"statement": "No file under prisma/migrations/ is deleted by this change (vs origin/main)",
|
|
99
115
|
"verifierType": "MACHINE",
|
|
100
|
-
"checkpointer": "bash -c \"test -z \\\"$(git diff --diff-filter=D --name-only origin/main -- prisma/migrations/)\\\"\""
|
|
116
|
+
"checkpointer": "bash -c \"test -z \\\"$(git diff --diff-filter=D --name-only origin/main -- prisma/migrations/)\\\"\"",
|
|
117
|
+
"referentKind": "EXTERNAL_FACT"
|
|
101
118
|
},
|
|
102
119
|
{
|
|
103
120
|
"statement": "Live-doc keeps its table structure after the edit (no rows/headings dropped)",
|
|
104
121
|
"verifierType": "MACHINE",
|
|
105
|
-
"checkpointer": "npx tsx scripts/verify-live-doc.ts <docId> docs/live-docs/<file>.md"
|
|
122
|
+
"checkpointer": "npx tsx scripts/verify-live-doc.ts <docId> docs/live-docs/<file>.md",
|
|
123
|
+
"referentKind": "AGENT_CONSTRUCTED_STATE"
|
|
106
124
|
}
|
|
107
125
|
]
|
|
108
126
|
```
|
|
@@ -120,17 +138,20 @@ One invariant criterion is usually enough — it's the guardrail, not the whole
|
|
|
120
138
|
{
|
|
121
139
|
"statement": "PUT /api/tasks/[id]/criteria rejects a second AGENT_DRAFT submission with 409",
|
|
122
140
|
"verifierType": "MACHINE",
|
|
123
|
-
"checkpointer": "npx jest __tests__/task-criteria.service.test.ts -t 'agent lock'"
|
|
141
|
+
"checkpointer": "npx jest __tests__/task-criteria.service.test.ts -t 'agent lock'",
|
|
142
|
+
"referentKind": "AGENT_CONSTRUCTED_STATE"
|
|
124
143
|
},
|
|
125
144
|
{
|
|
126
145
|
"statement": "The criteria section reads naturally as part of the task statement",
|
|
127
146
|
"verifierType": "HUMAN",
|
|
147
|
+
"referentKind": "AGENT_CONSTRUCTED_STATE",
|
|
128
148
|
"judgeSteps": "Open the task's Acceptance tab. Read the contract top to bottom. Pass if it scans as one coherent definition of done; send back if a criterion reads as boilerplate or contradicts another."
|
|
129
149
|
},
|
|
130
150
|
{
|
|
131
151
|
"statement": "Session-start injection shows the contract ahead of memory",
|
|
132
152
|
"verifierType": "MACHINE",
|
|
133
153
|
"checkpointer": "npx jest __tests__/cli/hook-runner-session-start-stdout.test.ts",
|
|
154
|
+
"referentKind": "AGENT_CONSTRUCTED_STATE",
|
|
134
155
|
"evidenceRequired": true
|
|
135
156
|
}
|
|
136
157
|
]
|
|
@@ -141,6 +162,7 @@ Fields:
|
|
|
141
162
|
- `statement` — required, ≤2000 chars.
|
|
142
163
|
- `verifierType` — `"MACHINE"` | `"HUMAN"`.
|
|
143
164
|
- `checkpointer` — required for MACHINE.
|
|
165
|
+
- `referentKind` — **required**, `"EXTERNAL_FACT"` | `"AGENT_CONSTRUCTED_STATE"` | `"PENDING_OUTCOME"` (see "referentKind" above). Your own tests/build/type-check are `AGENT_CONSTRUCTED_STATE`; `EXTERNAL_FACT` is gated (warned at set, 422 at verify) unless the checkpointer truly grounds external; `PENDING_OUTCOME` can't be MACHINE.
|
|
144
166
|
- `evidenceRequired` — optional, default false.
|
|
145
167
|
- `judgeSteps` — optional, ≤2000 chars; agent-drafted human-judging steps for a HUMAN criterion (see "judgeSteps" above).
|
|
146
168
|
- `id` — only in `--human` revisions (see below).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Fidelity read-outs (mechanical change-pattern)
|
|
2
|
+
|
|
3
|
+
`lumo fidelity` is the fidelity-axis read surface — pure, read-only analytics over what the acceptance loop already records, in the same family as `lumo criteria audit` and `lumo outcome rate`. It adds **no storage and no write path**; it only reads the LUM-609 delivery snapshots and the `CRITERION_CHANGED` audit trail.
|
|
4
|
+
|
|
5
|
+
## `lumo fidelity show <task>`
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
lumo fidelity show LUM-42
|
|
9
|
+
lumo fidelity show LUM-42 --json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For **each delivery** of the task (one append-only `TaskCriteriaSnapshot` per fresh IN_REVIEW entry — a reopen→re-deliver appends another), it discloses two compositions and deliberately stops there. There is **no single fidelity score and no good/bad direction verdict** — the read-out is purely mechanical (timing + op + checkpointer grounding; it never reads statement or diff semantics).
|
|
13
|
+
|
|
14
|
+
### 1. Grounding composition
|
|
15
|
+
|
|
16
|
+
Runs the same `effectiveReferentKind` classifier the LUM-605 verify gate uses over the **frozen** contract (delivery-time truth, not the since-edited `latest`), rolled into three buckets plus the full per-kind tally:
|
|
17
|
+
|
|
18
|
+
- **grounded** — `EXTERNAL_FACT` (anchored on a fact the agent cannot author: a git SHA/diff, CI status, a DB row). This count is a **machine-checkable _upper bound_** — the checkpointer heuristic can over-count external grounding, never under-count it.
|
|
19
|
+
- **self-confirming** — `AGENT_CONSTRUCTED_STATE` + `UNVERIFIED_ASSERTION` (passes by checking the agent's own output, incl. a declared-EXTERNAL_FACT checkpointer that doesn't truly ground external).
|
|
20
|
+
- **inconclusive** — `PENDING_OUTCOME` + `UNCLASSIFIED` (not mechanically resolvable here). **Surfaced, never silently swallowed.**
|
|
21
|
+
|
|
22
|
+
### 2. Independence signal
|
|
23
|
+
|
|
24
|
+
A criterion is a **backward-inference suspect** when it is both (a) in this delivery's frozen contract and (b) was `ADDED`/`UPDATED` at **round>0** (after work started) at or before the freeze — i.e. the contract the delivery rode on was bent after the work began rather than fixed up front. round-0 (initial-draft) criteria are clean; `DELETED` ops and edits recorded after the freeze (a later cycle) don't count. Each suspect shows its `op`, `round`, and `causeTag`. The read-out **counts and discloses — it does not judge** whether a given edit was legitimate sharpening or tampering (that direction call is left to a human / a later axis).
|
|
25
|
+
|
|
26
|
+
### Caveat (printed on every report)
|
|
27
|
+
|
|
28
|
+
The time anchor is the **work-start boundary** (`Task.workStartedAt`), so the independence signal detects a contract edited _after work started_ — not specifically _after output was produced_. A task with **no delivery snapshot** (never entered IN_REVIEW, or predates the LUM-609 freeze) is reported explicitly — "nothing to read yet", which is **not** a pass.
|
|
29
|
+
|
|
30
|
+
`--json` emits the full report (`taskId`, `workStartedAt`, `hasSnapshots`, `deliveries[]` with `grounding`/`independence`, `caveat`) for scripting.
|
|
31
|
+
|
|
32
|
+
**When to suggest**: when the user wants to see how externally-grounded a task's delivered acceptance contract actually was, or whether criteria were added/changed after work began (independence). It's a disclosure tool, not a gate — it never blocks DONE and emits no verdict.
|
|
@@ -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
|
|
@@ -40,7 +40,7 @@ lumo outcome record LUM-42 --note "downstream rewrote the API shape this task in
|
|
|
40
40
|
|
|
41
41
|
## `lumo outcome rate [--min N] [--json]`
|
|
42
42
|
|
|
43
|
-
The workspace-level **trust × post-hoc-fate** join (LUM-603). For every delivery that has a frozen forecast snapshot, it buckets the delivery by its **delivery-time
|
|
43
|
+
The workspace-level **trust × post-hoc-fate** join (LUM-603, LUM-606). For every delivery that has a frozen forecast snapshot, it buckets the delivery by its **delivery-time verification friction** — two raw counts (`verificationRounds`, `sendBackCount`) frozen at delivery from the append-only `VerificationRun` log, cut at `capturedAt`, then bucketed value-neutrally into `no-verification` / `single-round` / `multi-round-or-sendback` — and reports each bucket's post-hoc **REJECTED** rate from the well. The friction axis replaced the original sample-size forecast-confidence axis (LUM-606), which saturated: every mature-project delivery landed in `high`, so `high`-vs-`low` could never compare. The question it answers once data is sufficient: _do deliveries that took more verification friction get rejected by reality more (fragility) or less (scrutiny) often?_ The bucket names carry **no** good/bad direction — the correlation, not the label, decides.
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
lumo outcome rate
|
|
@@ -49,17 +49,17 @@ lumo outcome rate --min 20 --json
|
|
|
49
49
|
|
|
50
50
|
This is the falsifier discipline applied at the **query layer** — it refuses to manufacture a finding off a thin sample:
|
|
51
51
|
|
|
52
|
-
- A
|
|
53
|
-
- The per-
|
|
54
|
-
- The
|
|
52
|
+
- A bucket with fewer than `--min` (default 10) deliveries reads **`insufficient`** with the rate withheld — the raw `rejected/delivered` counts are still shown, but never divided into a believable proportion.
|
|
53
|
+
- The per-bucket rate is an explicit **lower bound** on true failure: the well only records rejections, so a zero-signal delivery is `INCONCLUSIVE` (counted in the denominator), never "satisfied".
|
|
54
|
+
- The **`single-round` vs `multi-round-or-sendback`** comparison asserts a direction **only** when both buckets are measured _and_ their 95% confidence intervals are disjoint; otherwise it stays **`inconclusive`**. `no-verification` is never a comparison arm (silence is not a friction level).
|
|
55
55
|
|
|
56
|
-
**Expect `inconclusive` today.** The well is freshly wired and
|
|
56
|
+
**Expect `inconclusive` today.** The well is freshly wired and the discriminating end of the friction axis is sparse (multi-round/send-back deliveries are rare) — that is the designed state, not a bug. The same command produces real strata once enough post-hoc signals accumulate against the populated friction buckets (the deferred outcome the skeleton exists to read).
|
|
57
57
|
|
|
58
|
-
> Scope note
|
|
58
|
+
> Scope note: friction is frozen **at delivery** and cut at `capturedAt`, so it is replayable and never reads a drifted current value. A delivery with no `VerificationRun` before capture is its own `no-verification` bucket — absence of verification is never folded into `single-round` (silence ≠ a clean pass).
|
|
59
59
|
|
|
60
60
|
## When to suggest
|
|
61
61
|
|
|
62
62
|
- A teammate says a shipped task **was reverted / rolled back / broke prod / got redone / was bypassed** — record it with `lumo outcome record` so the well carries reality's verdict.
|
|
63
63
|
- Auditing whether a "done" task actually held up — `lumo outcome show <task>` reads the well.
|
|
64
|
-
- Asking whether **
|
|
64
|
+
- Asking whether **verification friction predicts post-hoc fate** (do harder-to-verify deliveries fail more, or less?) — `lumo outcome rate`. Expect `inconclusive` until the well fills; it will never print a correlation the sample can't support.
|
|
65
65
|
- **Never** use it to mark something good: the well has no positive verdict by design. To assert a delivery met its contract, that is the acceptance loop (`lumo verify` / `lumo task status`), not the well.
|
|
@@ -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
|
|
|
@@ -21,10 +21,10 @@ lumo verify --note "…" --timeout 900 # per-checkpointer timeout in seconds
|
|
|
21
21
|
**`--note` is required (LUM-597)** whenever a round will actually be posted (the
|
|
22
22
|
task has MACHINE criteria). It is your one-line self-report — what you did and
|
|
23
23
|
why it is ready ("我改了 X,因为 Y 可验收"). When the round all-passes and the
|
|
24
|
-
task flips to
|
|
24
|
+
task flips to IN*REVIEW, the note is frozen as the task's **claim** (provenance
|
|
25
25
|
`AGENT` — the汇报者's own voice, not a summarizer paraphrase) and is checked
|
|
26
26
|
against the diff for faithfulness. Capturing the claim is deterministic ("不填不
|
|
27
|
-
让 post"); whether it is
|
|
27
|
+
让 post"); whether it is \_truthful* is the faithfulness audit's job — so don't
|
|
28
28
|
under- or over-state it. A missing/blank note is refused **before** the round is
|
|
29
29
|
posted, so no round is burned; just re-run with one. (An old CLI that omits it
|
|
30
30
|
still verifies — the claim degrades to the synthesized run-summary fallback.)
|
|
@@ -61,15 +61,16 @@ errors.
|
|
|
61
61
|
|
|
62
62
|
## Edge cases
|
|
63
63
|
|
|
64
|
-
| Case | Behavior
|
|
65
|
-
| ----------------------------------------------- |
|
|
66
|
-
| **No contract yet** | Error pointing at `lumo task criteria set`; draft the contract first (criteria.md golden rule).
|
|
67
|
-
| **HUMAN-only contract** (zero MACHINE criteria) | Nothing to run; CLI says so and suggests `lumo task update <id> --status in_review` for human review. No server write happens.
|
|
68
|
-
| **Partial round** | A round must cover every MACHINE criterion; the CLI always runs all of them and the server rejects partial rounds.
|
|
69
|
-
| **`REVIEW_ADDED` criteria** | Criteria added during review appear in the contract and are picked up automatically by the next round.
|
|
70
|
-
| **Session bound to a different task** | Server returns 409, surfaced as an error. No advisory printed; the verify round is rejected outright.
|
|
71
|
-
| **
|
|
72
|
-
| **
|
|
64
|
+
| Case | Behavior |
|
|
65
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| **No contract yet** | Error pointing at `lumo task criteria set`; draft the contract first (criteria.md golden rule). |
|
|
67
|
+
| **HUMAN-only contract** (zero MACHINE criteria) | Nothing to run; CLI says so and suggests `lumo task update <id> --status in_review` for human review. No server write happens. |
|
|
68
|
+
| **Partial round** | A round must cover every MACHINE criterion; the CLI always runs all of them and the server rejects partial rounds. |
|
|
69
|
+
| **`REVIEW_ADDED` criteria** | Criteria added during review appear in the contract and are picked up automatically by the next round. |
|
|
70
|
+
| **Session bound to a different task** | Server returns 409, surfaced as an error. No advisory printed; the verify round is rejected outright. |
|
|
71
|
+
| **Ungrounded EXTERNAL_FACT PASS** (LUM-605) | A reported PASS on a criterion declared `EXTERNAL_FACT` whose checkpointer doesn't invoke external tooling (e.g. `npx jest …`) is blocked with **422 — no round burned**; the task does **not** move to IN_REVIEW. Fix it for free: re-ground the checkpointer against a real external fact, or honestly re-declare `referentKind` as `AGENT_CONSTRUCTED_STATE` (`lumo task criteria set`), then re-run. See criteria.md "referentKind". |
|
|
72
|
+
| **Provably-unbound session** | Response carries `bindingAdvisory: 'unbound'`; prints `⚠ Working unbound — this verify ran from a Claude Code session not attached to the task.` Recorded as a `SESSION_BINDING_MISSING` boundary crossing (visible in `lumo task status` open crossings). Run `lumo session attach <LUM-N>` before the next verify. |
|
|
73
|
+
| **Unconfirmed session binding** | `bindingAdvisory: 'unconfirmed'` → softer advisory `⚠ Could not confirm this session is attached to the task.` Same remediation: `lumo session attach <LUM-N>`. |
|
|
73
74
|
|
|
74
75
|
## Round discipline
|
|
75
76
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fidelityShow = fidelityShow;
|
|
4
|
+
const config_1 = require("../lib/config");
|
|
5
|
+
const api_1 = require("../lib/api");
|
|
6
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
7
|
+
/**
|
|
8
|
+
* `lumo fidelity show <task>` — the LUM-610 mechanical change-pattern read-out
|
|
9
|
+
* (fidelity axis, second block, on the LUM-609 delivery-snapshot foundation).
|
|
10
|
+
*
|
|
11
|
+
* Per delivery snapshot it discloses two compositions and NOTHING else — no
|
|
12
|
+
* single score, no good/bad verdict (approach (a): purely mechanical):
|
|
13
|
+
* • grounding — how much of the FROZEN contract is externally grounded
|
|
14
|
+
* (EXTERNAL_FACT) vs self-confirming (agent-authored) vs inconclusive.
|
|
15
|
+
* `grounded` is a machine-checkable UPPER BOUND.
|
|
16
|
+
* • independence — criteria ADDED/UPDATED after work started (round>0) that
|
|
17
|
+
* the delivery rode on: backward-inference suspects. Direction left blank.
|
|
18
|
+
*/
|
|
19
|
+
const EFFECTIVE_KINDS = [
|
|
20
|
+
'EXTERNAL_FACT',
|
|
21
|
+
'AGENT_CONSTRUCTED_STATE',
|
|
22
|
+
'UNVERIFIED_ASSERTION',
|
|
23
|
+
'PENDING_OUTCOME',
|
|
24
|
+
'UNCLASSIFIED',
|
|
25
|
+
];
|
|
26
|
+
function authBase() {
|
|
27
|
+
const creds = (0, config_1.readCredentials)();
|
|
28
|
+
if (!creds)
|
|
29
|
+
return { error: 'not logged in. Run `lumo auth login` first.' };
|
|
30
|
+
const base = (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
|
|
31
|
+
const headers = {
|
|
32
|
+
Authorization: `Bearer ${creds.token}`,
|
|
33
|
+
};
|
|
34
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
35
|
+
if (sessionId)
|
|
36
|
+
headers['X-Lumo-Session-Id'] = sessionId;
|
|
37
|
+
return { base, headers };
|
|
38
|
+
}
|
|
39
|
+
function formatReport(report, taskId) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
lines.push(`Fidelity change-pattern — ${(0, sanitize_1.sanitizeField)(taskId)}`);
|
|
42
|
+
if (!report.hasSnapshots) {
|
|
43
|
+
lines.push(' No delivery snapshot on record (task never entered IN_REVIEW, or ' +
|
|
44
|
+
'predates the LUM-609 freeze). Nothing to read yet — not a pass.');
|
|
45
|
+
lines.push('');
|
|
46
|
+
lines.push(` ${report.caveat}`);
|
|
47
|
+
return lines.join('\n') + '\n';
|
|
48
|
+
}
|
|
49
|
+
report.deliveries.forEach((d, i) => {
|
|
50
|
+
const when = d.frozenAt.slice(0, 19).replace('T', ' ');
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push(` Delivery ${i + 1}/${report.deliveries.length} · frozen ${when}Z · ${(0, sanitize_1.sanitizeField)(d.trigger)} · ${d.total} criteria`);
|
|
53
|
+
const g = d.grounding;
|
|
54
|
+
lines.push(` grounding: grounded ${g.grounded} · self-confirming ${g.selfConfirming} · inconclusive ${g.inconclusive} (grounded = machine-checkable upper bound)`);
|
|
55
|
+
const kinds = EFFECTIVE_KINDS.filter(k => g.byEffectiveKind[k] > 0)
|
|
56
|
+
.map(k => `${k} ${g.byEffectiveKind[k]}`)
|
|
57
|
+
.join(', ');
|
|
58
|
+
lines.push(` effective kinds: ${kinds || '(none)'}`);
|
|
59
|
+
const ind = d.independence;
|
|
60
|
+
lines.push(` independence: ${ind.postStartChanges}/${ind.total} criteria ADDED/UPDATED after work started (backward-inference suspects; direction not judged)`);
|
|
61
|
+
for (const s of ind.suspects) {
|
|
62
|
+
const cause = s.causeTag ? ` · ${(0, sanitize_1.sanitizeField)(s.causeTag)}` : '';
|
|
63
|
+
lines.push(` ↳ ${(0, sanitize_1.sanitizeField)(s.criterionId)} ${s.op}@round${s.round}${cause} (${s.at.slice(0, 10)})`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(` ${report.caveat}`);
|
|
68
|
+
return lines.join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
async function fidelityShow(taskId, options = {}) {
|
|
71
|
+
if (!taskId || taskId.trim() === '') {
|
|
72
|
+
console.error('Error: a task is required: lumo fidelity show <task>');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const auth = authBase();
|
|
76
|
+
if ('error' in auth) {
|
|
77
|
+
console.error(`Error: ${auth.error}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${auth.base}/api/tasks/${encodeURIComponent(taskId)}/fidelity`, { headers: auth.headers });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
console.error(`Error: could not reach Lumo API (${msg})`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
if (res.status === 401) {
|
|
90
|
+
console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 404) {
|
|
94
|
+
console.error(`Error: task ${(0, sanitize_1.sanitizeField)(taskId)} not found.`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
console.error(`Error: could not read the fidelity change-pattern (HTTP ${res.status}).`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const report = (await res.json());
|
|
102
|
+
if (options.json) {
|
|
103
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write(formatReport(report, taskId));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
@@ -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
|
}
|
|
@@ -141,10 +141,12 @@ async function outcomeShow(taskId) {
|
|
|
141
141
|
}
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
+
/** Width of the widest bucket label, for aligned columns. */
|
|
145
|
+
const BUCKET_LABEL_WIDTH = 'multi-round-or-sendback'.length;
|
|
144
146
|
/** Render the report client-side (same pattern as `lumo cost` / `outcome show`). */
|
|
145
147
|
function formatRate(report) {
|
|
146
148
|
const lines = [];
|
|
147
|
-
lines.push('Trust × post-hoc fate — delivery-time
|
|
149
|
+
lines.push('Trust × post-hoc fate — delivery-time verification friction vs the outcome well');
|
|
148
150
|
lines.push(' (rejection rate is a LOWER BOUND: the well only records rejections; ' +
|
|
149
151
|
'no signal = INCONCLUSIVE, never "satisfied")');
|
|
150
152
|
lines.push(` ${report.totalDeliveries} snapshotted deliveries · min ${report.minSamples} per bracket`);
|
|
@@ -157,10 +159,10 @@ function formatRate(report) {
|
|
|
157
159
|
const pct = ((b.rejectionRate ?? 0) * 100).toFixed(1);
|
|
158
160
|
const lo = ((b.interval?.lower ?? 0) * 100).toFixed(1);
|
|
159
161
|
const hi = ((b.interval?.upper ?? 0) * 100).toFixed(1);
|
|
160
|
-
lines.push(` ${b.bracket.padEnd(
|
|
162
|
+
lines.push(` ${b.bracket.padEnd(BUCKET_LABEL_WIDTH)} ≥${pct}% rejected (${b.rejected}/${b.delivered}, 95% CI ${lo}–${hi}%)`);
|
|
161
163
|
}
|
|
162
164
|
else {
|
|
163
|
-
lines.push(` ${b.bracket.padEnd(
|
|
165
|
+
lines.push(` ${b.bracket.padEnd(BUCKET_LABEL_WIDTH)} insufficient (${b.rejected}/${b.delivered} deliveries < ${report.minSamples} — rate withheld)`);
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
const verdict = report.comparison.status === 'measured'
|
|
@@ -171,10 +173,12 @@ function formatRate(report) {
|
|
|
171
173
|
}
|
|
172
174
|
/**
|
|
173
175
|
* `lumo outcome rate [--min N] [--json]` — read the workspace trust × post-hoc-
|
|
174
|
-
* fate join: per delivery-time
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* `
|
|
176
|
+
* fate join: per delivery-time verification-friction bucket (LUM-606:
|
|
177
|
+
* no-verification / single-round / multi-round-or-sendback, frozen at delivery),
|
|
178
|
+
* the post-hoc REJECTED rate from the outcome well. Honest by construction — thin
|
|
179
|
+
* buckets read `insufficient` (no fabricated rate) and the single-round
|
|
180
|
+
* vs multi-round-or-sendback comparison stays `inconclusive` until the well has
|
|
181
|
+
* enough signal to tell the buckets apart.
|
|
178
182
|
*/
|
|
179
183
|
async function outcomeRate(options = {}) {
|
|
180
184
|
const auth = authBase();
|
|
@@ -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` +
|
|
@@ -197,6 +197,11 @@ async function taskCriteriaSet(identifier, options) {
|
|
|
197
197
|
if (data.judgeStepsWarning) {
|
|
198
198
|
process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.judgeStepsWarning)}\n`);
|
|
199
199
|
}
|
|
200
|
+
// LUM-605: EXTERNAL_FACT criteria whose checkpointer doesn't ground external
|
|
201
|
+
// — stored, but the verify loop will refuse a PASS on them until re-grounded.
|
|
202
|
+
if (data.referentWarning) {
|
|
203
|
+
process.stdout.write(`⚠ ${(0, sanitize_1.sanitizeField)(data.referentWarning)}\n`);
|
|
204
|
+
}
|
|
200
205
|
const readbackIssues = verifyReadback(criteriaItems, data.criteria);
|
|
201
206
|
if (readbackIssues.length > 0) {
|
|
202
207
|
for (const issue of readbackIssues) {
|
package/dist/cli/src/index.js
CHANGED
|
@@ -48,6 +48,7 @@ const session_status_1 = require("./commands/session-status");
|
|
|
48
48
|
const next_1 = require("./commands/next");
|
|
49
49
|
const cost_1 = require("./commands/cost");
|
|
50
50
|
const criteria_audit_1 = require("./commands/criteria-audit");
|
|
51
|
+
const fidelity_1 = require("./commands/fidelity");
|
|
51
52
|
const verify_1 = require("./commands/verify");
|
|
52
53
|
const verdict_1 = require("./commands/verdict");
|
|
53
54
|
const crossing_explain_1 = require("./commands/crossing-explain");
|
|
@@ -285,16 +286,25 @@ criteria
|
|
|
285
286
|
.command('audit')
|
|
286
287
|
.description('Show the workspace referent-kind distribution + self-confirming-green ratio (GET /api/criteria/audit)')
|
|
287
288
|
.action(wrap(() => (0, criteria_audit_1.criteriaAudit)()));
|
|
289
|
+
const fidelity = program
|
|
290
|
+
.command('fidelity')
|
|
291
|
+
.description('Mechanical fidelity read-outs (LUM-610)');
|
|
292
|
+
fidelity
|
|
293
|
+
.command('show <task>')
|
|
294
|
+
.description('Per delivery snapshot (LUM-609): the grounding composition of the frozen contract (grounded / self-confirming / inconclusive) and the independence signal (criteria ADDED/UPDATED after work started = backward-inference suspects). A composition table, not a single score — direction is never judged. inconclusive is surfaced, never swallowed.')
|
|
295
|
+
.option('--json', 'Emit the report as JSON')
|
|
296
|
+
.action(wrap((task, options) => (0, fidelity_1.fidelityShow)(task, options)));
|
|
288
297
|
const session = program
|
|
289
298
|
.command('session')
|
|
290
299
|
.description('Manage per-terminal coding-session context');
|
|
291
300
|
session
|
|
292
301
|
.command('attach <identifier>')
|
|
293
|
-
.description('Attach the currently-running Claude Code session (CLAUDE_CODE_SESSION_ID) to a task.
|
|
294
|
-
.
|
|
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)));
|
|
295
305
|
session
|
|
296
306
|
.command('status')
|
|
297
|
-
.description('Show
|
|
307
|
+
.description('Show what this Claude Code session is bound to: a task, a milestone (STEWARD governance session), or nothing.')
|
|
298
308
|
.action(wrap(() => (0, session_status_1.sessionStatus)()));
|
|
299
309
|
const task = program
|
|
300
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
|
+
}
|
|
@@ -47,6 +47,20 @@ function wordMatches(word, tools) {
|
|
|
47
47
|
// exact tool, or a tool-prefixed filename like jest-t.ts / vitest.config
|
|
48
48
|
return tools.some(t => word === t || word.startsWith(`${t}-`) || word.startsWith(`${t}.`));
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* No-op external invocations (LUM-605): forms whose exit code and output are
|
|
52
|
+
* constant w.r.t. external state (`git --version`, `gh --help`, …). An external
|
|
53
|
+
* tool invoked this way establishes no external fact, so it must never ground
|
|
54
|
+
* EXTERNAL — this is half of the C-hole fix (the other half is the consumption
|
|
55
|
+
* requirement below). Kept deliberately small/conservative: a smaller set means
|
|
56
|
+
* fewer real external checks get mistaken for no-ops (we'd rather under-flag a
|
|
57
|
+
* decoy than false-downgrade a genuine check, which enforcement now penalizes).
|
|
58
|
+
*/
|
|
59
|
+
const NOOP_FLAG_TOKENS = new Set(['--version', '--help']);
|
|
60
|
+
/** No-op SUBCOMMAND forms — only when they are the first word after the tool
|
|
61
|
+
* (`git version`, `gh help`). Not matched as a bare flag-anywhere, so we don't
|
|
62
|
+
* trip on an unrelated positional like a psql `-h <host>` (host, not help). */
|
|
63
|
+
const NOOP_SUBCOMMANDS = new Set(['version', 'help']);
|
|
50
64
|
/**
|
|
51
65
|
* Given a raw command word (possibly `npx jest`, `npm run build`, etc.),
|
|
52
66
|
* normalize to the effective tool name. Returns null for pure wrapper tokens
|
|
@@ -138,6 +152,143 @@ function collectCommandWords(script) {
|
|
|
138
152
|
}
|
|
139
153
|
return result;
|
|
140
154
|
}
|
|
155
|
+
function isExternalTool(word) {
|
|
156
|
+
return wordMatches(word, EXTERNAL_TOOLS);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse one shell command segment (already split off sequencing/pipe operators)
|
|
160
|
+
* into its effective tool + whether it is a no-op invocation. Mirrors the
|
|
161
|
+
* wrapper-skipping of {@link collectCommandWords} (npx / sudo / command / npm
|
|
162
|
+
* run), then reads the post-tool tokens to flag no-op forms (LUM-605).
|
|
163
|
+
*/
|
|
164
|
+
function parseInvocation(segment) {
|
|
165
|
+
const cleaned = segment
|
|
166
|
+
.replace(/['"]/g, ' ')
|
|
167
|
+
.replace(/\[|\]/g, ' ')
|
|
168
|
+
.replace(/\$\{?[^}]*}?/g, ' ')
|
|
169
|
+
.trim();
|
|
170
|
+
if (!cleaned)
|
|
171
|
+
return null;
|
|
172
|
+
const tokens = cleaned.split(/\s+/).filter(Boolean);
|
|
173
|
+
let i = 0;
|
|
174
|
+
while (i < tokens.length) {
|
|
175
|
+
const lower = tokens[i].toLowerCase();
|
|
176
|
+
if (lower === 'npx' || lower === 'sudo' || lower === 'command') {
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (lower === 'npm' && tokens[i + 1]?.toLowerCase() === 'run') {
|
|
181
|
+
i += 2;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (i >= tokens.length)
|
|
187
|
+
return null;
|
|
188
|
+
const tool = normalizeCommandWord(tokens[i].toLowerCase());
|
|
189
|
+
const rest = tokens.slice(i + 1).map(t => t.toLowerCase());
|
|
190
|
+
const isNoop = rest.some(t => NOOP_FLAG_TOKENS.has(t)) ||
|
|
191
|
+
(rest.length > 0 && NOOP_SUBCOMMANDS.has(rest[0]));
|
|
192
|
+
return { tool, isNoop };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Does this script contain a CONSUMED, non-no-op external command (LUM-605)?
|
|
196
|
+
* "Consumed" — the discriminator that closes the C hole — means the external
|
|
197
|
+
* command's output or exit actually reaches the check's verdict:
|
|
198
|
+
* - captured in a `$(...)` / backtick substitution (output → data → assertion);
|
|
199
|
+
* - piped into a downstream stage (`ext | …`);
|
|
200
|
+
* - the terminal command of the whole script (its exit status IS the verdict).
|
|
201
|
+
* A non-terminal external word whose output is discarded (the decoy
|
|
202
|
+
* `git --version && cat frozen.txt | grep`), or any no-op invocation, does NOT
|
|
203
|
+
* ground EXTERNAL.
|
|
204
|
+
*/
|
|
205
|
+
function groundsExternal(script) {
|
|
206
|
+
const noComments = script.replace(/(^|\s)#[^\n]*/gm, ' ');
|
|
207
|
+
return scanScript(noComments);
|
|
208
|
+
}
|
|
209
|
+
/** Pull out substitutions / `bash -c` payloads (recursing into them) then walk
|
|
210
|
+
* statements → `&&`/`||` segments → pipeline stages to test each consumed slot. */
|
|
211
|
+
function scanScript(text) {
|
|
212
|
+
let remaining = text;
|
|
213
|
+
let foundInNested = false;
|
|
214
|
+
remaining = remaining.replace(/\$\(([^)]*)\)/g, (_m, inner) => {
|
|
215
|
+
if (scanConsumed(inner))
|
|
216
|
+
foundInNested = true;
|
|
217
|
+
return ' ';
|
|
218
|
+
});
|
|
219
|
+
remaining = remaining.replace(/`([^`]*)`/g, (_m, inner) => {
|
|
220
|
+
if (scanConsumed(inner))
|
|
221
|
+
foundInNested = true;
|
|
222
|
+
return ' ';
|
|
223
|
+
});
|
|
224
|
+
remaining = remaining.replace(/\b(?:bash|sh)\b[^'"]*-c\s+'([^']*)'/g, (_m, payload) => {
|
|
225
|
+
if (scanScript(payload))
|
|
226
|
+
foundInNested = true;
|
|
227
|
+
return ' ';
|
|
228
|
+
});
|
|
229
|
+
remaining = remaining.replace(/\b(?:bash|sh)\b[^'"]*-c\s+"([^"]*)"/g, (_m, payload) => {
|
|
230
|
+
if (scanScript(payload))
|
|
231
|
+
foundInNested = true;
|
|
232
|
+
return ' ';
|
|
233
|
+
});
|
|
234
|
+
if (foundInNested)
|
|
235
|
+
return true;
|
|
236
|
+
const statements = remaining
|
|
237
|
+
.split(/;|\n/)
|
|
238
|
+
.map(s => s.trim())
|
|
239
|
+
.filter(Boolean);
|
|
240
|
+
for (let si = 0; si < statements.length; si++) {
|
|
241
|
+
const terminalStatement = si === statements.length - 1;
|
|
242
|
+
const segments = statements[si].split(/&&|\|\|/)
|
|
243
|
+
.map(s => s.trim())
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
for (let gi = 0; gi < segments.length; gi++) {
|
|
246
|
+
const terminalSegment = gi === segments.length - 1;
|
|
247
|
+
const stages = segments[gi].split(/\|/)
|
|
248
|
+
.map(s => s.trim())
|
|
249
|
+
.filter(Boolean);
|
|
250
|
+
for (let pi = 0; pi < stages.length; pi++) {
|
|
251
|
+
const finalStage = pi === stages.length - 1;
|
|
252
|
+
const inv = parseInvocation(stages[pi]);
|
|
253
|
+
if (!inv || !isExternalTool(inv.tool) || inv.isNoop)
|
|
254
|
+
continue;
|
|
255
|
+
const pipedIntoConsumer = !finalStage;
|
|
256
|
+
const exitIsVerdict = finalStage && terminalSegment && terminalStatement;
|
|
257
|
+
if (pipedIntoConsumer || exitIsVerdict)
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
/** Substitution context: the captured output is data fed to the surrounding
|
|
265
|
+
* assertion, so a non-no-op external command word in ANY position is consumed. */
|
|
266
|
+
function scanConsumed(inner) {
|
|
267
|
+
let remaining = inner;
|
|
268
|
+
let found = false;
|
|
269
|
+
remaining = remaining.replace(/\$\(([^)]*)\)/g, (_m, x) => {
|
|
270
|
+
if (scanConsumed(x))
|
|
271
|
+
found = true;
|
|
272
|
+
return ' ';
|
|
273
|
+
});
|
|
274
|
+
remaining = remaining.replace(/`([^`]*)`/g, (_m, x) => {
|
|
275
|
+
if (scanConsumed(x))
|
|
276
|
+
found = true;
|
|
277
|
+
return ' ';
|
|
278
|
+
});
|
|
279
|
+
if (found)
|
|
280
|
+
return true;
|
|
281
|
+
const segments = remaining
|
|
282
|
+
.split(/&&|\|\||;|\||\n/)
|
|
283
|
+
.map(s => s.trim())
|
|
284
|
+
.filter(Boolean);
|
|
285
|
+
for (const seg of segments) {
|
|
286
|
+
const inv = parseInvocation(seg);
|
|
287
|
+
if (inv && isExternalTool(inv.tool) && !inv.isNoop)
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
141
292
|
function classifyCheckpointerGrounding(checkpointer) {
|
|
142
293
|
if (!checkpointer || checkpointer.trim() === '')
|
|
143
294
|
return 'NONE';
|
|
@@ -145,11 +296,13 @@ function classifyCheckpointerGrounding(checkpointer) {
|
|
|
145
296
|
// Fast path for known two-word external phrases (e.g. prisma migrate status)
|
|
146
297
|
if (EXTERNAL_PHRASES.some(p => lower.includes(p)))
|
|
147
298
|
return 'EXTERNAL';
|
|
148
|
-
const words = collectCommandWords(
|
|
299
|
+
const words = collectCommandWords(lower);
|
|
149
300
|
if (words.length === 0)
|
|
150
301
|
return 'NONE';
|
|
151
|
-
// Strongest signal wins: EXTERNAL > AGENT_HARNESS > ASSERTION_ONLY
|
|
152
|
-
|
|
302
|
+
// Strongest signal wins: EXTERNAL > AGENT_HARNESS > ASSERTION_ONLY. EXTERNAL
|
|
303
|
+
// is gated on a CONSUMED, non-no-op external command (LUM-605) — a no-op or
|
|
304
|
+
// output-discarded external word falls through to harness/assertion.
|
|
305
|
+
if (groundsExternal(lower))
|
|
153
306
|
return 'EXTERNAL';
|
|
154
307
|
if (words.some(w => wordMatches(w, HARNESS_TOOLS)))
|
|
155
308
|
return 'AGENT_HARNESS';
|