@lumoai/cli 1.26.0 → 1.28.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.
@@ -19,20 +19,20 @@ which lumo && lumo whoami
19
19
 
20
20
  The command catalog below is a **map**: it lists every command grouped by domain with a one-line summary. **Detailed flags, examples, output formats, and "when to suggest" guidance live in the `references/` files** — when a user request lands in a domain, **Read the matching reference file before composing the command**. Don't run a command from memory if its flags/edge-cases matter; open the reference first.
21
21
 
22
- | Domain | Read this reference |
23
- | --------------------------------------------------------------------------------- | -------------------------------------------------------------- |
24
- | `setup`, `auth login/logout`, `whoami`, `update` | [references/onboarding.md](references/onboarding.md) |
25
- | `task context`, retrieval (`slack/web/figma context`, `comments list`, `pr show`) | [references/task-context.md](references/task-context.md) |
26
- | `task create/update/list/show/comment`, `next` | [references/tasks.md](references/tasks.md) |
27
- | `task artifact*`, `task figma*` | [references/artifacts-figma.md](references/artifacts-figma.md) |
28
- | `task criteria set/list`, drafting the acceptance contract | [references/criteria.md](references/criteria.md) |
22
+ | Domain | Read this reference |
23
+ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
24
+ | `setup`, `auth login/logout`, `whoami`, `update` | [references/onboarding.md](references/onboarding.md) |
25
+ | `task context`, retrieval (`slack/web/figma context`, `comments list`, `pr show`) | [references/task-context.md](references/task-context.md) |
26
+ | `task create/update/list/show/comment`, `next` | [references/tasks.md](references/tasks.md) |
27
+ | `task artifact*`, `task figma*` | [references/artifacts-figma.md](references/artifacts-figma.md) |
28
+ | `task criteria set/list`, drafting the acceptance contract | [references/criteria.md](references/criteria.md) |
29
29
  | `verify`, `task status` — machine verification loop, claim-done flow, self-check/resume | [references/verify.md](references/verify.md) |
30
- | `project list`, `milestone*` | [references/milestones.md](references/milestones.md) |
31
- | `doc*` | [references/docs.md](references/docs.md) |
32
- | `sprint*` | [references/sprints.md](references/sprints.md) |
33
- | `task/project memory`, `memory promote/rm` | [references/memory.md](references/memory.md) |
34
- | `session attach/status/detach/wrap`, git-suggest on start, Layer-2 review | [references/sessions.md](references/sessions.md) |
35
- | `worktree add/rm/list` (local dev tooling) | [references/worktree.md](references/worktree.md) |
30
+ | `project list`, `milestone*` | [references/milestones.md](references/milestones.md) |
31
+ | `doc*` | [references/docs.md](references/docs.md) |
32
+ | `sprint*` | [references/sprints.md](references/sprints.md) |
33
+ | `task/project memory`, `memory promote/rm` | [references/memory.md](references/memory.md) |
34
+ | `session attach/status/detach/wrap`, git-suggest on start, Layer-2 review | [references/sessions.md](references/sessions.md) |
35
+ | `worktree add/rm/list` (local dev tooling) | [references/worktree.md](references/worktree.md) |
36
36
 
37
37
  ## Command catalog
38
38
 
@@ -98,6 +98,8 @@ The command catalog below is a **map**: it lists every command grouped by domain
98
98
  **Documents** — see [docs.md](references/docs.md)
99
99
 
100
100
  - `lumo doc create/update/show/list/delete` — document CRUD
101
+ - `lumo doc show <doc> --raw` — print the byte-identical markdown source of the last markdown upload (the only legal edit base; errors when no source is stored — never falls back to the lossy HTML→md render)
102
+ - `lumo doc diff <doc> --file <local.md>` — compare the server-side markdown source against a local file (exit 0 identical, 1 with unified diff)
101
103
  - `lumo doc move` — reparent under a parent / to root
102
104
  - `lumo doc bind/unbind <doc> <task>` — task linkage
103
105
  - `lumo doc share/unshare/share-list` — member sharing
@@ -149,10 +151,10 @@ Typical flow when a user says "help me with LUM-42":
149
151
  1. `lumo session attach LUM-42` — bind this session
150
152
  2. `lumo task context LUM-42` — load background
151
153
  3. Review unresolved items, PR-review todos, and the task description
152
- 4. **If the task has no acceptance criteria** (context shows the draft reminder instead of a contract): draft 3–7 outcome-level criteria and submit them with `lumo task criteria set` **before writing the first line of code** — see [criteria.md](references/criteria.md) for the drafting guide
154
+ 4. **If the task has no acceptance criteria** (context shows the draft reminder instead of a contract): draft outcome-level criteria sized to the task (3–7 for a typical multi-file task; 1–2 for a micro task) and submit them with `lumo task criteria set` **before writing the first line of code** — see [criteria.md](references/criteria.md) for the drafting guide
153
155
  5. Begin working on the task
154
156
  6. **Before claiming the work is done: run `lumo verify`** — the machine half of the acceptance loop. Fix failures and re-run (round cap 3). On all-pass the task moves to IN_REVIEW and you stop; never set DONE yourself after a verify loop — that adjudication is human-only. See [verify.md](references/verify.md)
155
157
 
156
158
  **Status-first recovery:** when you pick a task back up — a new session resuming earlier work, or a task that came back after a rejected verification round / review findings — run `lumo task status` **before** re-reading code or planning. It tells you where the loop stands (current round, what passed, what's unmet and why, any REVIEW_ADDED criteria appended during review) so you don't redo finished work or miss the reason it bounced. See [verify.md](references/verify.md)
157
159
 
158
- **Git-suggest at start:** when the session is unbound, session-start may infer the task from the git branch / recent commits and print a suggestion — `Detected LUM-N (from branch name). Run lumo session attach LUM-N to bind.` (or `from recent commits`) — **without** binding. Confirm it's the right task, then run `lumo session attach <LUM-N>` yourself (binding only happens on an explicit attach). See [sessions.md](references/sessions.md) for the full session-start behavior.
160
+ **Git-suggest at start:** when the session is unbound, session-start may infer the task from the git branch / recent commits (any team prefix, e.g. `SPEC-12`, not just `LUM-N`) and print a suggestion — `Detected LUM-N (from branch name). Run lumo session attach LUM-N to bind.` (or `from recent commits`) — **without** binding. Confirm it's the right task, then run `lumo session attach <LUM-N>` yourself (binding only happens on an explicit attach). See [sessions.md](references/sessions.md) for the full session-start behavior.
@@ -17,8 +17,10 @@ contract, you MUST draft and submit criteria before starting implementation:
17
17
  1. Read the task description, comments, linked resources, and memory first —
18
18
  the contract distills what "done" means, so understand the task before
19
19
  writing it.
20
- 2. Draft **3–7 criteria** (soft range — the server warns outside it but never
21
- rejects; if you genuinely need more, merge related checks instead).
20
+ 2. Draft **3–7 criteria** for a typical multi-file task (soft range — the
21
+ server warns outside it but never rejects; if you genuinely need more,
22
+ merge related checks instead). **Scale the count down for small tasks** —
23
+ see "Scale the contract to the task size" below.
22
24
  3. Submit with `lumo task criteria set <task> --file <criteria.json>`.
23
25
  Submission **locks the contract for agent edits** — get it right before
24
26
  submitting, because afterwards only human revisions (`--human`) can change it.
@@ -27,6 +29,23 @@ Once you (the agent) have started work, you can NOT change your own contract.
27
29
  That's by design: the contract is what your work gets judged against, not a
28
30
  to-do list you trim to fit what you built.
29
31
 
32
+ ## Scale the contract to the task size
33
+
34
+ The 3–7 range is calibrated for typical multi-file tasks. The criterion count
35
+ must track the size of the work — it is not a fixed ritual:
36
+
37
+ - **Micro task** (expected to fit a single session, blast radius of one or
38
+ two files — a docs tweak, a copy change, a small targeted fix): **1–2
39
+ criteria IS a complete contract** — one targeted MACHINE criterion plus at
40
+ most one HUMAN criterion.
41
+ - **Never pad toward the soft floor.** A filler criterion (a restated
42
+ baseline, a third angle on the same check) dilutes the contract and taxes
43
+ every verification round. For a micro task the below-minimum warning is
44
+ expected — ignore it.
45
+ - **Shrink the contract, never skip it.** Every task still drafts and locks a
46
+ contract before the first line of code; task size only changes how many
47
+ criteria that takes.
48
+
30
49
  ## How to write good criteria
31
50
 
32
51
  - **Outcome-level definition of done, not micro-steps.** A criterion states a
@@ -129,8 +148,10 @@ before a `--human` revision. Empty contract prints a drafting pointer.
129
148
  binding.
130
149
  - **`lumo task context`**: the `## Acceptance criteria (contract)` section appears after the
131
150
  task description, before memory.
132
- - Review-time gap findings (`REVIEW_ADDED`, appended at the round they
133
- surface) arrive via the verification loop, not via `criteria set`.
151
+ - Review-time gap findings are appended at the round they surface and show up
152
+ in the contract automatically `REVIEW_ADDED` provenance via human review
153
+ paths; findings a human raises in conversation are transcribed with
154
+ `--human` + `--cause` (see verify.md "Review-time drift habits").
134
155
 
135
156
  ## After the contract: the verification loop
136
157
 
@@ -85,17 +85,50 @@ lumo doc update RFC --tag final --add-tag oops
85
85
  - User wants to revise an existing doc.
86
86
  - After running `lumo doc list` or `doc show`, if the user wants to change a doc's scope, title, or content.
87
87
 
88
- ### `lumo doc show <doc>` — print one document's detail
88
+ ### `lumo doc show <doc> [--raw]` — print one document's detail
89
89
 
90
- Prints a key:value header (id, title, scope, project, created/updated timestamps, mentioned tasks) and the content rendered back to markdown.
90
+ Default mode prints a key:value header (id, title, scope, project, created/updated timestamps, mentioned tasks) and the content rendered back to markdown.
91
91
 
92
92
  ```bash
93
93
  lumo doc show "RFC: doc CLI"
94
+ lumo doc show cmd_xxx --raw > base.md # byte-identical edit base
94
95
  ```
95
96
 
96
- Note: the markdown rendered by `doc show` is best-effort. Round-trip via `doc show > tmp.md && doc update --file tmp.md` is NOT guaranteed to be a no-op.
97
+ **`--raw` (LUM-408)** prints the byte-identical markdown source of the last markdown upload — no header, no trailing newline added. The server stores the raw markdown (`sourceMarkdown`) alongside the rendered HTML on every markdown write (`doc create/update --content/--file/stdin`, gdoc import/sync), so `--raw` output IS a legal edit base: `doc show --raw > base.md`, edit, `doc update --file base.md` round-trips losslessly.
97
98
 
98
- Use when the user wants the current state or content of a doc without loading full task context.
99
+ - A web-editor (HTML-direct) edit or revision restore **invalidates** the stored source — the doc's markdown source is gone until the next markdown upload.
100
+ - When no source is stored (legacy doc or after an HTML edit), `--raw` **fails with exit 1 and a rebuild hint** — it never silently falls back to the lossy HTML→markdown reverse render (that fallback flattened tables: LUM-349). Rebuild flow: `doc show` (rendered) → reconstruct the markdown faithfully → `doc update --file rebuilt.md` → `--raw` works from then on.
101
+ - Raw output is verbatim (unsanitized) by design — redirect it to a file rather than reading it in a terminal when the doc's provenance is uncertain.
102
+
103
+ Note: the markdown rendered by **default-mode** `doc show` is still best-effort (tables flatten). Round-trip via `doc show > tmp.md && doc update --file tmp.md` is NOT a no-op — use `--raw` as the edit base instead.
104
+
105
+ Use default mode when the user wants to read a doc; use `--raw` whenever the output will be edited and uploaded back.
106
+
107
+ ### When to suggest `doc show --raw`
108
+
109
+ - Before any `doc update --file` that starts from existing remote content — fetch the base with `--raw`, never from rendered `doc show` output.
110
+ - User asks "what's the exact source of this doc", or an agent needs a faithful local copy of a live doc.
111
+ - If `--raw` errors (no stored source), walk the rebuild flow above instead of editing the rendered output.
112
+
113
+ ### `lumo doc diff <doc> --file <local.md>` — compare remote markdown source vs a local file
114
+
115
+ Compares the server-side stored markdown source (the byte-identical last markdown upload) against a local file, making remote/local split-brain visible on demand.
116
+
117
+ | Flag | Type | Notes |
118
+ | --------------- | ------ | ---------------------------------------------------------------------------------- |
119
+ | `--file <path>` | string | Required. Local markdown file; same project-local sandbox as `doc update --file`. |
120
+
121
+ Exit codes: **0** = byte-identical (prints `Clean: …`), **1** = divergent (prints a unified diff, `--- remote/<id> (sourceMarkdown)` vs `+++ local/<file>`) or error. A doc without a stored markdown source errors explicitly (upload a markdown base once, then diff works).
122
+
123
+ ```bash
124
+ lumo doc diff cmd_xxx --file docs/live-docs/research-intake-ledger.md
125
+ ```
126
+
127
+ ### When to suggest `doc diff`
128
+
129
+ - Before uploading a locally edited file with `doc update --file` — check whether the remote source moved since the local copy was taken (prevents stale-upload clobbering, the #460 incident class).
130
+ - When a repo-tracked markdown source (e.g. `docs/live-docs/`) and the live doc may have drifted and the user asks which is current.
131
+ - After a suspected concurrent edit: a clean diff (exit 0) proves remote and local are in sync.
99
132
 
100
133
  ### `lumo doc list [flags]` — list documents
101
134
 
@@ -298,7 +331,7 @@ Synced cmd_xxx "Quarterly Plan" from Google
298
331
  The CLI does **not** currently support:
299
332
 
300
333
  - `--from-editor` (interactive $EDITOR).
301
- - Lossless markdown round-trip.
334
+ - Lossless markdown round-trip from **rendered** `doc show` output (use `doc show --raw` — lossless whenever a markdown source is stored).
302
335
  - Reordering siblings within the same parent (`--before` / `--after`); use the Web UI for that.
303
336
 
304
337
  ### When to suggest session binding for docs
@@ -45,17 +45,29 @@ the bound task; `lumo project memory add ...` records onto its project.
45
45
 
46
46
  The command prints **one** of these outcome lines:
47
47
 
48
- | Output line | Meaning |
49
- |---|---|
50
- | `Added <CATEGORY> <SCOPE> memory …` | No near-duplicate found; stored as a new row. |
51
- | `Merged into existing memory <id> (near-duplicate) …` | Near-duplicate found; the existing row was refined/updated in-place (UPDATE). |
52
- | `Superseded an existing memory; new version <id> …` | New content contradicts an old memory; old row invalidated, new row created (SUPERSEDE). |
53
- | `Skipped — duplicate of existing memory <id>, nothing written …` | Exact or near-exact duplicate; no write performed (NOOP). |
48
+ | Output line | Meaning |
49
+ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
50
+ | `Added <CATEGORY> <SCOPE> memory …` | No near-duplicate found; stored as a new row. |
51
+ | `Merged into existing memory <id> (near-duplicate) …` | Near-duplicate found; the existing row was refined/updated in-place (UPDATE). |
52
+ | `Superseded an existing memory; new version <id> …` | New content contradicts an old memory; old row invalidated, new row created (SUPERSEDE). |
53
+ | `Skipped — duplicate of existing memory <id>, nothing written …` | Exact or near-exact duplicate; no write performed (NOOP). |
54
54
 
55
55
  Content is **always normalized to English** before storing — the memory store has
56
56
  a single canonical language. If you supply text in another language the CLI
57
57
  translates it automatically; the stored memory will be in English.
58
58
 
59
+ ### Lumo memory vs the harness memory tool
60
+
61
+ Claude Code / the Claude API may expose a file-based **memory tool** (a
62
+ `/memories` directory the model writes autonomously). That store is the agent's
63
+ private scratchpad — un-grounded, free-form, invisible to the team, and outside
64
+ Lumo's flywheel. **Project engineering lessons always go through `lumo task/project
65
+ memory add`** — never record them only in the harness memory tool, and never bulk-copy
66
+ harness memory files into Lumo memory (they are ungrounded drafts; Lumo's
67
+ extract→ground→reconcile write path is the only vetted entry). The two stores are
68
+ layered, not mirrored: transient working notes may live in the harness tool,
69
+ durable team knowledge lives in Lumo memory.
70
+
59
71
  ### When to record a memory (worthiness)
60
72
 
61
73
  Record only knowledge that is **invisible in the codebase** — the _why_ behind a
@@ -10,15 +10,18 @@ infer the task from local git so it can **suggest** one — it never binds for y
10
10
 
11
11
  - It reads the **current branch name** first (e.g. `lumo/LUM-145-...`), then the
12
12
  **most recent commit subjects** (e.g. `... [LUM-145]`), extracting the first
13
- `LUM-<n>`.
13
+ task identifier. Detection is **prefix-agnostic** (LUM-419): any team prefix
14
+ matches — `SPEC-12` as much as `LUM-145` — using the same pattern the server
15
+ uses to link PR branches to tasks. Well-known acronym-number tokens
16
+ (`UTF-8`, `SHA-256`, `ISO-8601`, …) are skipped, never suggested.
14
17
  - On a hit it prints a single suggestion line and stops — the session stays
15
18
  **unbound** and no context is injected yet:
16
19
  `Detected LUM-145 (from branch name). Run lumo session attach LUM-145 to bind.`
17
20
  (the basis reads `from recent commits` when the hit came from commit subjects instead of the branch name)
18
21
  No task title is shown here because nothing was fetched; the title, memory,
19
22
  and PR-review todos appear only once you actually attach.
20
- - No match (detached HEAD, a non-lumo branch with no tagged commits, not a git
21
- repo) → it degrades to the normal unbound prompt.
23
+ - No match (detached HEAD, a branch with no identifier and no tagged commits,
24
+ not a git repo) → it degrades to the normal unbound prompt.
22
25
 
23
26
  **Agent guidance:** when you see a suggestion line, confirm the inferred task is
24
27
  the one the user wants, then run `lumo session attach <LUM-N>` (followed by
@@ -71,6 +71,30 @@ the failures — re-running without changes burns a round and (at round 3)
71
71
  pages a human. A FAIL round never changes task status; only an all-pass round
72
72
  moves it (to IN_REVIEW, never further).
73
73
 
74
+ ## Review-time drift habits (gap findings)
75
+
76
+ A problem discovered during acceptance/review that the contract does NOT
77
+ cover is a **gap finding** — record it in the contract, never just fix it
78
+ silently:
79
+
80
+ 1. **Append it on the spot.** Transcribe the human's finding as a criterion
81
+ via `lumo task criteria set <task> --file <desired-final-list> --human` —
82
+ the review-added semantics: the gap surfaced at review time, at the
83
+ current round.
84
+ 2. **Tag why the contract drifted** with `--cause
85
+ <NEW_INFO|SCOPE_CHANGE|DRAFT_BLIND_SPOT|GRANULARITY|OTHER>`. Gap findings
86
+ are usually `DRAFT_BLIND_SPOT` (the draft missed it) or `NEW_INFO`
87
+ (information that didn't exist at drafting time).
88
+ 3. **Then bounce.** The appended criterion shows up in `lumo task status`
89
+ nextActions and the next verify round picks it up automatically — no
90
+ side-channel to-do list.
91
+
92
+ How to read drift: information-lag and requirement-movement drift
93
+ (`NEW_INFO`, `SCOPE_CHANGE`) is healthy — don't optimize it away.
94
+ `DRAFT_BLIND_SPOT` clusters feed back into the drafting guide. **Zero drift
95
+ across many tasks is a red flag, not a trophy** — it usually means contracts
96
+ are too thin or state only sure-win clauses that can never be found wanting.
97
+
74
98
  ## lumo task status — the read half (self-check entry point)
75
99
 
76
100
  `lumo task status [task] [--json]` is the read-only counterpart of the loop
@@ -102,7 +126,7 @@ what's unmet and why (the exact failure tails), and how many rounds are left.
102
126
  - Header: task identifier/title/status + `verification round N/3` (round 0 =
103
127
  never verified) + an escalation warning when the machine loop is exhausted.
104
128
  - **Criteria** — every criterion as `<glyph> <id> [TYPE] SOURCE@rN
105
- statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with its
129
+ statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with its
106
130
  checkpointer and latest verdict line (evidence pointer on pass, failure
107
131
  tail on fail). `REVIEW_ADDED@rN` provenance is visible per row.
108
132
  - **History** — one line per recorded round: `rN · timestamp · X PASS / Y FAIL`.
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.docDiff = docDiff;
4
+ const config_1 = require("../lib/config");
5
+ const api_1 = require("../lib/api");
6
+ const resolve_doc_id_1 = require("../lib/resolve-doc-id");
7
+ const sanitize_1 = require("../lib/sanitize");
8
+ const doc_input_1 = require("../lib/doc-input");
9
+ const path_guard_1 = require("../lib/path-guard");
10
+ const unified_diff_1 = require("../lib/unified-diff");
11
+ /**
12
+ * `lumo doc diff <doc> --file <local.md>` (LUM-408).
13
+ *
14
+ * Compares the server-side markdown source (Document.sourceMarkdown — the
15
+ * byte-identical last markdown upload) against a local file, so a
16
+ * remote/local split-brain is visible on demand instead of discovered from
17
+ * memory after a stale upload clobbers someone's edit.
18
+ *
19
+ * Exit codes: 0 = sources byte-identical, 1 = divergent (or error).
20
+ */
21
+ async function docDiff(reference, opts) {
22
+ if (!reference) {
23
+ console.error('Error: missing <doc>. Usage: lumo doc diff <doc> --file <local.md>');
24
+ return 1;
25
+ }
26
+ if (!opts.file) {
27
+ console.error('Error: --file <local.md> is required for doc diff');
28
+ return 1;
29
+ }
30
+ const creds = (0, config_1.readCredentials)();
31
+ if (!creds) {
32
+ console.error('Error: not logged in. Run `lumo auth login` first.');
33
+ return 1;
34
+ }
35
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
36
+ // Same sandbox as doc create/update --file: project-local, non-sensitive.
37
+ const check = (0, path_guard_1.checkArtifactFilePath)(opts.file);
38
+ if (!check.ok) {
39
+ console.error(check.reason === 'unreadable'
40
+ ? `Error: ${(0, doc_input_1.unreadableFileMessage)(opts.file)}`
41
+ : `Error: refusing to read ${opts.file} — ${check.detail}. ` +
42
+ `--file must be a non-sensitive path inside the project directory.`);
43
+ return 1;
44
+ }
45
+ const localText = await (0, doc_input_1.readFileUtf8)(check.resolved);
46
+ const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
47
+ if (!id) {
48
+ console.error(`Error: Document not found: ${reference}`);
49
+ return 1;
50
+ }
51
+ const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
52
+ headers: { Authorization: `Bearer ${creds.token}` },
53
+ });
54
+ if (!res.ok) {
55
+ const text = await res.text();
56
+ console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
57
+ return 1;
58
+ }
59
+ const data = (await res.json());
60
+ const d = data.document;
61
+ if (!d) {
62
+ console.error('Error: server returned an empty document response');
63
+ return 1;
64
+ }
65
+ if (typeof d.sourceMarkdown !== 'string') {
66
+ console.error(`Error: ${d.id} has no stored markdown source to diff against (last edit ` +
67
+ `was HTML-direct or predates markdown source storage). Upload a markdown ` +
68
+ `base once (\`lumo doc update ${d.id} --file <base.md>\`) and diff from then on.`);
69
+ return 1;
70
+ }
71
+ if (d.sourceMarkdown === localText) {
72
+ console.log(`Clean: remote markdown source of ${d.id} matches ${opts.file} (${Buffer.byteLength(localText, 'utf8')} bytes)`);
73
+ return;
74
+ }
75
+ const diff = (0, unified_diff_1.formatUnifiedDiff)(d.sourceMarkdown, localText, `remote/${d.id} (sourceMarkdown)`, `local/${opts.file}`);
76
+ // Byte-level divergence with identical line sequences (e.g. trailing
77
+ // newline only) still counts as divergent — say so explicitly.
78
+ console.log(diff !== ''
79
+ ? (0, sanitize_1.sanitizeField)(diff)
80
+ : `Divergent: byte-level difference only (e.g. trailing newline) between remote ${d.id} and ${opts.file}`);
81
+ return 1;
82
+ }
@@ -27,9 +27,9 @@ function formatShowOutput(vm) {
27
27
  const header = lines.join('\n');
28
28
  return vm.bodyMarkdown ? `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}` : header;
29
29
  }
30
- async function docShow(reference) {
30
+ async function docShow(reference, opts = {}) {
31
31
  if (!reference) {
32
- console.error('Error: missing <doc>. Usage: lumo doc show <doc>');
32
+ console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw]');
33
33
  return 1;
34
34
  }
35
35
  const creds = (0, config_1.readCredentials)();
@@ -58,6 +58,23 @@ async function docShow(reference) {
58
58
  console.error('Error: server returned an empty document response');
59
59
  return 1;
60
60
  }
61
+ if (opts.raw) {
62
+ // Byte-identical markdown source of the last markdown upload (LUM-408).
63
+ // Written verbatim (no header, no sanitization, no added newline) so the
64
+ // output is a legal edit base: `doc show --raw > base.md` round-trips.
65
+ // No silent fallback to the lossy HTML→markdown reverse render — that
66
+ // fallback is exactly what flattened tables in LUM-349.
67
+ if (typeof d.sourceMarkdown !== 'string') {
68
+ console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
69
+ `or predates markdown source storage). --raw refuses to fall back to the ` +
70
+ `lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
71
+ `reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
72
+ `— from then on --raw works.`);
73
+ return 1;
74
+ }
75
+ process.stdout.write(d.sourceMarkdown);
76
+ return;
77
+ }
61
78
  // Server returns `contentMarkdown` derived from the HTML body (LUM-83+).
62
79
  // Fall back to parsing the raw content as legacy Tiptap JSON for docs
63
80
  // written before the storage shape changed.
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.program = void 0;
37
38
  const fs = __importStar(require("fs"));
38
39
  const os = __importStar(require("os"));
39
40
  const path = __importStar(require("path"));
@@ -107,6 +108,7 @@ const doc_import_gdoc_1 = require("./commands/doc-import-gdoc");
107
108
  const doc_sync_1 = require("./commands/doc-sync");
108
109
  const doc_update_1 = require("./commands/doc-update");
109
110
  const doc_show_1 = require("./commands/doc-show");
111
+ const doc_diff_1 = require("./commands/doc-diff");
110
112
  const doc_list_1 = require("./commands/doc-list");
111
113
  const doc_delete_1 = require("./commands/doc-delete");
112
114
  const doc_bind_1 = require("./commands/doc-bind");
@@ -122,9 +124,16 @@ const worktree_rm_1 = require("./commands/worktree-rm");
122
124
  const worktree_list_1 = require("./commands/worktree-list");
123
125
  const update_check_1 = require("./lib/update-check");
124
126
  const sanitize_1 = require("./lib/sanitize");
127
+ // Introspection mode: tooling (scripts/analysis grammar extraction) imports
128
+ // this module to walk the registered commander tree without running the CLI.
129
+ // Skips package.json resolution (which assumes the compiled dist/ layout),
130
+ // update checks, local-state cleanup, and parseAsync. Never set for real runs.
131
+ const isIntrospect = process.env.LUMO_CLI_INTROSPECT === '1';
125
132
  // Resolve package.json relative to __dirname so this works regardless of how
126
133
  // deep the compiled output ends up (flat dist/ or nested dist/cli/src/).
127
- const pkg = require(path.resolve(__dirname, '../../..', 'package.json'));
134
+ const pkg = isIntrospect
135
+ ? { name: '@lumoai/cli', version: '0.0.0-introspect' }
136
+ : require(path.resolve(__dirname, '../../..', 'package.json'));
128
137
  // Detached background-refresh worker: re-entry point for the spawn() in
129
138
  // maybeRefreshInBackground(). Fetches latest, writes cache, exits — skipping
130
139
  // commander.parseAsync() below.
@@ -134,7 +143,7 @@ if (isUpdateCheckWorker) {
134
143
  .catch(() => { })
135
144
  .finally(() => process.exit(0));
136
145
  }
137
- else {
146
+ else if (!isIntrospect) {
138
147
  (0, update_check_1.printUpdateNoticeIfAny)(pkg.name, pkg.version);
139
148
  (0, update_check_1.maybeRefreshInBackground)(pkg.name);
140
149
  }
@@ -142,8 +151,7 @@ else {
142
151
  // is now server-authoritative; the legacy global pointer and the
143
152
  // per-session sentinel directory are both obsolete. Failures are
144
153
  // swallowed so a permission glitch doesn't prevent CLI invocation.
145
- ;
146
- (() => {
154
+ if (!isIntrospect) {
147
155
  const dir = process.env.LUMO_CONFIG_DIR || path.join(os.homedir(), '.lumo');
148
156
  try {
149
157
  fs.rmSync(path.join(dir, 'current-task.json'), { force: true });
@@ -153,7 +161,7 @@ else {
153
161
  fs.rmSync(path.join(dir, 'sessions'), { recursive: true, force: true });
154
162
  }
155
163
  catch { }
156
- })();
164
+ }
157
165
  const collect = (val, acc) => [...acc, val];
158
166
  function wrap(fn) {
159
167
  return async (...args) => {
@@ -178,6 +186,7 @@ const program = new commander_1.Command()
178
186
  // created via .command() inherit these settings.
179
187
  .showSuggestionAfterError(true)
180
188
  .showHelpAfterError('(run the command with --help to list its valid flags and arguments)');
189
+ exports.program = program;
181
190
  const auth = program.command('auth').description('Manage Lumo authentication');
182
191
  auth
183
192
  .command('login')
@@ -665,8 +674,14 @@ doc
665
674
  .action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
666
675
  doc
667
676
  .command('show <doc>')
668
- .description('Show document header and body (doc = title or cuid)')
669
- .action(wrap(reference => (0, doc_show_1.docShow)(reference)));
677
+ .description('Show document header and body (doc = title or cuid). --raw prints the byte-identical markdown source of the last markdown upload (a legal edit base); it errors when no markdown source is stored instead of falling back to the lossy HTML→markdown render.')
678
+ .option('--raw', 'Print the stored markdown source verbatim (no header); errors if absent')
679
+ .action(wrap((reference, opts) => (0, doc_show_1.docShow)(reference, opts)));
680
+ doc
681
+ .command('diff <doc>')
682
+ .description('Compare the server-side markdown source against a local file. Exit 0 when byte-identical, 1 with a unified diff when divergent. Requires the doc to have a stored markdown source.')
683
+ .requiredOption('--file <path>', 'Local markdown file to compare against')
684
+ .action(wrap((reference, opts) => (0, doc_diff_1.docDiff)(reference, opts)));
670
685
  doc
671
686
  .command('list')
672
687
  .description('List documents visible to the current user')
@@ -876,7 +891,7 @@ for (const [slug, description] of HOOK_SUBCOMMANDS) {
876
891
  .option('--agent <token>', 'Coding agent that owns this session (e.g. claude-code, codex). Baked in by `lumo setup --agent`.')
877
892
  .action(wrap((opts) => (0, hook_1.hookCommand)(slug, opts.agent)));
878
893
  }
879
- if (!isUpdateCheckWorker) {
894
+ if (!isUpdateCheckWorker && !isIntrospect) {
880
895
  program.parseAsync(process.argv).catch(err => {
881
896
  const msg = err instanceof Error ? err.message : String(err);
882
897
  console.error(`Error: ${(0, sanitize_1.sanitizeField)(msg)}`);
@@ -1,24 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.matchTaskIdentifier = matchTaskIdentifier;
3
+ exports.matchTaskIdentifier = void 0;
4
4
  exports.extractTaskFromGit = extractTaskFromGit;
5
5
  const child_process_1 = require("child_process");
6
+ const task_identifier_1 = require("../../../shared/src/task-identifier");
7
+ Object.defineProperty(exports, "matchTaskIdentifier", { enumerable: true, get: function () { return task_identifier_1.matchTaskIdentifier; } });
6
8
  /**
7
9
  * How many recent commit subjects to scan when the branch name carries no
8
10
  * task id (e.g. on a generic feature branch or detached HEAD).
9
11
  */
10
12
  const DEFAULT_COMMIT_DEPTH = 20;
11
- const TASK_RE = /LUM-(\d+)/i;
12
- /**
13
- * Pull the first `LUM-<n>` token out of arbitrary text (a branch name or a
14
- * commit subject) and normalize it to upper case. Returns null when no task
15
- * id is present. The leading `-` in the pattern means the bare word `lumo`
16
- * never matches.
17
- */
18
- function matchTaskIdentifier(text) {
19
- const m = text.match(TASK_RE);
20
- return m ? `LUM-${m[1]}` : null;
21
- }
22
13
  /**
23
14
  * Run a git subcommand and return trimmed stdout, or '' on any failure
24
15
  * (non-zero exit, spawn error, not a repository, timeout). Never throws.
@@ -41,17 +32,18 @@ function gitOutput(args, cwd) {
41
32
  /**
42
33
  * Infer the task to work on from local git: prefer the current branch name
43
34
  * (e.g. `lumo/LUM-145-...`), then fall back to the most recent commit
44
- * subjects (e.g. `... [LUM-145]`). Returns null when nothing matches
45
- * detached HEAD (branch reads `HEAD`), a non-lumo branch with no tagged
46
- * commits, or a directory that is not a git repository all degrade to null.
35
+ * subjects (e.g. `... [LUM-145]`). Any team prefix matches (SPEC-12 as much
36
+ * as LUM-145). Returns null when nothing matches detached HEAD (branch
37
+ * reads `HEAD`), a branch with no identifier and no tagged commits, or a
38
+ * directory that is not a git repository all degrade to null.
47
39
  */
48
40
  function extractTaskFromGit(cwd, commitDepth = DEFAULT_COMMIT_DEPTH) {
49
41
  const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
50
- const fromBranch = matchTaskIdentifier(branch);
42
+ const fromBranch = (0, task_identifier_1.matchTaskIdentifier)(branch);
51
43
  if (fromBranch)
52
44
  return { identifier: fromBranch, source: 'branch' };
53
45
  const log = gitOutput(['log', '-n', String(commitDepth), '--format=%s'], cwd);
54
- const fromLog = matchTaskIdentifier(log);
46
+ const fromLog = (0, task_identifier_1.matchTaskIdentifier)(log);
55
47
  if (fromLog)
56
48
  return { identifier: fromLog, source: 'commit' };
57
49
  return null;
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ /**
3
+ * Minimal line-based unified diff (LUM-408, `lumo doc diff`).
4
+ *
5
+ * Pure and dependency-free: the CLI ships only commander + markdown-it, and
6
+ * shelling out to system `diff` would not be portable. Equality (the exit
7
+ * code) is decided on raw bytes by the caller — this renderer only has to
8
+ * make the divergence readable, so a plain LCS with a size guard is enough.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.formatUnifiedDiff = formatUnifiedDiff;
12
+ const CONTEXT_LINES = 3;
13
+ /** Above this old×new line product the LCS table is too big — degrade to a single whole-block hunk. */
14
+ const MAX_LCS_CELLS = 4_000_000;
15
+ function splitLines(text) {
16
+ return text.split('\n');
17
+ }
18
+ /** LCS-based op list for the (already prefix/suffix-trimmed) middle section. */
19
+ function diffOps(oldLines, newLines) {
20
+ const n = oldLines.length;
21
+ const m = newLines.length;
22
+ if (n * m > MAX_LCS_CELLS) {
23
+ return [
24
+ ...oldLines.map(text => ({ type: 'del', text })),
25
+ ...newLines.map(text => ({ type: 'add', text })),
26
+ ];
27
+ }
28
+ // lcs[i][j] = LCS length of oldLines[i:] vs newLines[j:]
29
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
30
+ for (let i = n - 1; i >= 0; i--) {
31
+ for (let j = m - 1; j >= 0; j--) {
32
+ lcs[i][j] =
33
+ oldLines[i] === newLines[j]
34
+ ? lcs[i + 1][j + 1] + 1
35
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
36
+ }
37
+ }
38
+ const ops = [];
39
+ let i = 0;
40
+ let j = 0;
41
+ while (i < n && j < m) {
42
+ if (oldLines[i] === newLines[j]) {
43
+ ops.push({ type: 'ctx', text: oldLines[i] });
44
+ i++;
45
+ j++;
46
+ }
47
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
48
+ ops.push({ type: 'del', text: oldLines[i] });
49
+ i++;
50
+ }
51
+ else {
52
+ ops.push({ type: 'add', text: newLines[j] });
53
+ j++;
54
+ }
55
+ }
56
+ while (i < n)
57
+ ops.push({ type: 'del', text: oldLines[i++] });
58
+ while (j < m)
59
+ ops.push({ type: 'add', text: newLines[j++] });
60
+ return ops;
61
+ }
62
+ /**
63
+ * Render a unified diff between two texts. Returns '' when the line
64
+ * sequences are identical (callers decide byte-equality separately —
65
+ * e.g. a trailing-newline-only difference still flips the exit code).
66
+ */
67
+ function formatUnifiedDiff(oldText, newText, oldLabel, newLabel) {
68
+ const oldAll = splitLines(oldText);
69
+ const newAll = splitLines(newText);
70
+ // Trim the common prefix/suffix so the LCS only sees the changed middle.
71
+ let prefix = 0;
72
+ while (prefix < oldAll.length &&
73
+ prefix < newAll.length &&
74
+ oldAll[prefix] === newAll[prefix]) {
75
+ prefix++;
76
+ }
77
+ let suffix = 0;
78
+ while (suffix < oldAll.length - prefix &&
79
+ suffix < newAll.length - prefix &&
80
+ oldAll[oldAll.length - 1 - suffix] === newAll[newAll.length - 1 - suffix]) {
81
+ suffix++;
82
+ }
83
+ const middleOps = diffOps(oldAll.slice(prefix, oldAll.length - suffix), newAll.slice(prefix, newAll.length - suffix));
84
+ if (!middleOps.some(op => op.type !== 'ctx'))
85
+ return '';
86
+ // Re-attach trimmed context so hunks can carry CONTEXT_LINES around edits.
87
+ const ops = [
88
+ ...oldAll.slice(0, prefix).map(text => ({ type: 'ctx', text })),
89
+ ...middleOps,
90
+ ...oldAll
91
+ .slice(oldAll.length - suffix)
92
+ .map(text => ({ type: 'ctx', text })),
93
+ ];
94
+ // Group ops into hunks: a change plus up to CONTEXT_LINES of context on
95
+ // each side; nearby changes merge into one hunk.
96
+ const lines = [`--- ${oldLabel}`, `+++ ${newLabel}`];
97
+ let oldLineNo = 1;
98
+ let newLineNo = 1;
99
+ let idx = 0;
100
+ while (idx < ops.length) {
101
+ if (ops[idx].type === 'ctx') {
102
+ oldLineNo++;
103
+ newLineNo++;
104
+ idx++;
105
+ continue;
106
+ }
107
+ // Found a change — open a hunk starting CONTEXT_LINES back.
108
+ let hunkStart = idx;
109
+ let ctxBack = 0;
110
+ while (hunkStart > 0 && ops[hunkStart - 1].type === 'ctx' && ctxBack < CONTEXT_LINES) {
111
+ hunkStart--;
112
+ ctxBack++;
113
+ }
114
+ // Extend forward: include changes separated by ≤ 2×CONTEXT_LINES context.
115
+ let hunkEnd = idx;
116
+ let scan = idx;
117
+ while (scan < ops.length) {
118
+ if (ops[scan].type !== 'ctx') {
119
+ hunkEnd = scan;
120
+ scan++;
121
+ continue;
122
+ }
123
+ let run = 0;
124
+ while (scan + run < ops.length && ops[scan + run].type === 'ctx')
125
+ run++;
126
+ if (scan + run < ops.length && run <= CONTEXT_LINES * 2) {
127
+ scan += run;
128
+ continue;
129
+ }
130
+ break;
131
+ }
132
+ const tail = Math.min(hunkEnd + CONTEXT_LINES, ops.length - 1);
133
+ const hunkOps = ops.slice(hunkStart, tail + 1);
134
+ const oldStart = oldLineNo - ctxBack;
135
+ const newStart = newLineNo - ctxBack;
136
+ const oldCount = hunkOps.filter(o => o.type !== 'add').length;
137
+ const newCount = hunkOps.filter(o => o.type !== 'del').length;
138
+ lines.push(`@@ -${oldStart}${oldCount === 1 ? '' : `,${oldCount}`} +${newStart}${newCount === 1 ? '' : `,${newCount}`} @@`);
139
+ for (const op of hunkOps) {
140
+ const marker = op.type === 'ctx' ? ' ' : op.type === 'del' ? '-' : '+';
141
+ lines.push(`${marker}${op.text}`);
142
+ }
143
+ // Advance the line counters across everything the hunk consumed.
144
+ for (let k = idx; k <= tail; k++) {
145
+ const t = ops[k].type;
146
+ if (t !== 'add')
147
+ oldLineNo++;
148
+ if (t !== 'del')
149
+ newLineNo++;
150
+ }
151
+ idx = tail + 1;
152
+ }
153
+ return lines.join('\n');
154
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TASK_IDENTIFIER_PATTERN = void 0;
4
+ exports.matchTaskIdentifier = matchTaskIdentifier;
5
+ /**
6
+ * Single source of truth for the task-identifier pattern (LUM-419).
7
+ *
8
+ * Consumed by both sides so they cannot drift:
9
+ * - server: lib/integrations/github/branch.ts (webhook branch → task linking)
10
+ * - CLI: cli/src/lib/git-task.ts (session-start git-suggest)
11
+ *
12
+ * A task identifier is `<TEAM>-<n>` where TEAM is the team's configured
13
+ * prefix (`Team.identifier`, 1–10 letters) — e.g. LUM-419, SPEC-123. The
14
+ * prefix is NOT hardcoded to any one team.
15
+ */
16
+ exports.TASK_IDENTIFIER_PATTERN = String.raw `\b([A-Za-z]{1,10}-\d+)\b`;
17
+ /**
18
+ * Acronym-number tokens that match the identifier shape but are never task
19
+ * ids. Commit subjects routinely contain these ("fix UTF-8 handling",
20
+ * "use SHA-256"), so the matcher skips them instead of suggesting a bogus
21
+ * session attach. Compared against the uppercased prefix segment.
22
+ */
23
+ const NON_TASK_PREFIXES = new Set([
24
+ 'AES',
25
+ 'CVE',
26
+ 'GPT',
27
+ 'HTTP',
28
+ 'ISO',
29
+ 'RFC',
30
+ 'RSA',
31
+ 'SHA',
32
+ 'TLS',
33
+ 'UTF',
34
+ ]);
35
+ /**
36
+ * Extract the first task identifier from arbitrary text (a branch name or a
37
+ * commit subject), normalized to upper case. Tokens whose prefix is a known
38
+ * non-task acronym (UTF-8, SHA-256, …) are skipped; later genuine matches in
39
+ * the same text still win. Returns null when nothing matches.
40
+ */
41
+ function matchTaskIdentifier(text) {
42
+ const re = new RegExp(exports.TASK_IDENTIFIER_PATTERN, 'g');
43
+ for (const m of text.matchAll(re)) {
44
+ const identifier = m[1].toUpperCase();
45
+ const prefix = identifier.slice(0, identifier.lastIndexOf('-'));
46
+ if (NON_TASK_PREFIXES.has(prefix))
47
+ continue;
48
+ return identifier;
49
+ }
50
+ return null;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",