@lumoai/cli 1.29.0 → 1.30.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.
@@ -0,0 +1,148 @@
1
+ # lumo verify — machine verification loop
2
+
3
+ `lumo verify` is the machine half of the acceptance system (Acceptance v1,
4
+ LUM-343). It executes every **MACHINE** criterion's checkpointer in the local
5
+ repo, reports one structured PASS/FAIL verdict per criterion to the server,
6
+ and prints what to do next. The judge lives server-side: round numbering, the
7
+ 3-round cap, escalation, and the IN_REVIEW transition all happen there
8
+ (execution on the client, adjudication on the server).
9
+
10
+ ## The claim-done rule
11
+
12
+ **Before claiming a task is complete — in conversation, in a wrap-up, or by
13
+ touching its status — run `lumo verify`.** The loop replaces "I read the code
14
+ and it looks done" with executed evidence.
15
+
16
+ ```
17
+ lumo verify # session-bound task
18
+ lumo verify LUM-42 # explicit task (overrides the session binding)
19
+ lumo verify --timeout 900 # per-checkpointer timeout in seconds (default 600)
20
+ ```
21
+
22
+ ## What one round does
23
+
24
+ 1. Loads the task's acceptance contract and picks out MACHINE criteria.
25
+ 2. Runs each checkpointer locally (shell, cwd = current directory), one at a
26
+ time, echoing PASS/FAIL as it goes.
27
+ 3. POSTs the structured verdicts; the server records one VerificationRun per
28
+ criterion at round = previous max + 1 and mirrors each verdict as a
29
+ TaskActivity event.
30
+ 4. Prints the round outcome:
31
+ - **All PASS** → the task transitions to **IN_REVIEW** (existing state
32
+ machine + TASK_IN_REVIEW notification). **Stop here.** Human
33
+ adjudication and any HUMAN criteria take over; never set DONE yourself.
34
+ - **Any FAIL** → task status is untouched; the unmet criteria are printed
35
+ as next actions (statement, checkpointer, failure tail). Fix and re-run.
36
+ - **Round 3 still failing** → the loop escalates: a human is notified
37
+ (AGENT_VERIFY, requires action) and further `lumo verify` rounds are
38
+ rejected with 409. **Stop retrying**; fix only what the human directs.
39
+
40
+ Exit code 0 = all passed (or nothing to run); 1 = failures, escalation, or
41
+ errors.
42
+
43
+ ## Verdict semantics (what the CLI sends)
44
+
45
+ - checkpointer exits 0 → `PASS` with evidence `cmd:<command>#exit=0`
46
+ - non-zero exit → `FAIL`, reason = output tail, enum `CRITERION_UNMET`
47
+ - spawn failure / timeout → `FAIL`, enum `CHECK_EXECUTION_ERROR`
48
+
49
+ evidencePointer is **not free text** — the server only accepts
50
+ `commit:<hash>`, `file:<path>:<line>`, or `cmd:<command>#exit=<code>`.
51
+ Verdicts are PASS|FAIL only; the agent path cannot write HUMAN verdicts or
52
+ `PASS_WITH_FOLLOWUP` (red line — those enter via human-initiated UI paths
53
+ only).
54
+
55
+ ## Edge cases
56
+
57
+ - **No contract yet** → error pointing at `lumo task criteria set`; draft the
58
+ contract first (criteria.md golden rule).
59
+ - **HUMAN-only contract (zero MACHINE criteria)** → nothing to run; the CLI
60
+ says so and suggests handing off for human review
61
+ (`lumo task update <id> --status in_review`). No server write happens.
62
+ - **A round must cover every MACHINE criterion** — the CLI always runs all of
63
+ them; the server rejects partial rounds.
64
+ - Criteria added during review (`REVIEW_ADDED`) appear in the contract and
65
+ are picked up automatically by the next round.
66
+
67
+ ## Round discipline
68
+
69
+ Rounds are a hard budget of 3, not a retry loop. Between rounds, actually fix
70
+ the failures — re-running without changes burns a round and (at round 3)
71
+ pages a human. A FAIL round never changes task status; only an all-pass round
72
+ moves it (to IN_REVIEW, never further).
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
+
98
+ ## lumo task status — the read half (self-check entry point)
99
+
100
+ `lumo task status [task] [--json]` is the read-only counterpart of the loop
101
+ (LUM-344): pure read, milliseconds, no LLM, never writes — running it costs
102
+ nothing and burns no round. Defaults to the session-bound task; an explicit
103
+ identifier overrides.
104
+
105
+ ```
106
+ lumo task status # session-bound task
107
+ lumo task status LUM-42 # explicit task
108
+ lumo task status --json # versioned machine-readable payload
109
+ ```
110
+
111
+ ### When to run it
112
+
113
+ **Status-first recovery:** run it FIRST — before re-reading code or
114
+ planning — whenever you:
115
+
116
+ - resume a task in a new session (yours or another agent's earlier work);
117
+ - come back after a verification round was rejected (`lumo verify` failed);
118
+ - were told the task bounced in review (REVIEW_ADDED criteria may have been
119
+ appended at the round they surfaced — they show up here automatically).
120
+
121
+ It answers "where does the loop stand": what already passed (don't redo it),
122
+ what's unmet and why (the exact failure tails), and how many rounds are left.
123
+
124
+ ### What it prints
125
+
126
+ - Header: task identifier/title/status + `verification round N/3` (round 0 =
127
+ never verified) + an escalation warning when the machine loop is exhausted.
128
+ - **Criteria** — every criterion as `<glyph> <id> [TYPE] SOURCE@rN
129
+ statement` (✓ latest verdict passed / ✗ failed / ○ no verdict yet) with its
130
+ checkpointer and latest verdict line (evidence pointer on pass, failure
131
+ tail on fail). `REVIEW_ADDED@rN` provenance is visible per row.
132
+ - **History** — one line per recorded round: `rN · timestamp · X PASS / Y FAIL`.
133
+ - **Last round failures** — the most recent round's FAIL verdicts with their
134
+ rejection reasons (why the last round bounced).
135
+ - **Next actions** — the unmet criteria (latest verdict is not a pass:
136
+ failed or never verified, HUMAN ones included). This list IS the plan —
137
+ it is recomputed from the event log on every read, never maintained
138
+ separately. Empty + rounds recorded = awaiting human adjudication.
139
+
140
+ ### --json contract
141
+
142
+ `--json` emits the full read model with a top-level `version` field
143
+ (currently `1`). The schema is versioned: breaking shape changes bump the
144
+ major; additive fields don't. Pin on `version` when scripting against it.
145
+
146
+ `status` reads; `verify` judges. Running status never starts a round, never
147
+ escalates, and never changes task state — loop rules (cap 3, IN_REVIEW on
148
+ all-pass, human-only DONE) live entirely in `lumo verify` and the server.
@@ -51,7 +51,7 @@ async function docList(opts) {
51
51
  }
52
52
  else {
53
53
  const params = new URLSearchParams();
54
- if (opts.scope && opts.scope !== 'all') {
54
+ if (opts.scope && opts.scope.toLowerCase() !== 'all') {
55
55
  const v = (0, doc_create_1.normalizeScope)(opts.scope);
56
56
  if (!v) {
57
57
  console.error(`Error: invalid scope "${opts.scope}". Allowed: personal, workspace, all`);
@@ -74,7 +74,7 @@ async function docList(opts) {
74
74
  }
75
75
  const { documents } = (await res.json());
76
76
  let rows = documents;
77
- if (opts.task && opts.scope && opts.scope !== 'all') {
77
+ if (opts.task && opts.scope && opts.scope.toLowerCase() !== 'all') {
78
78
  const v = (0, doc_create_1.normalizeScope)(opts.scope);
79
79
  if (v)
80
80
  rows = rows.filter(r => r.visibility === v);
@@ -69,6 +69,8 @@ async function docSectionEdit(mode, reference, opts) {
69
69
  }
70
70
  if (ifRevision !== undefined)
71
71
  body.ifRevision = ifRevision;
72
+ if (opts.allowShrink)
73
+ body.allowShrink = true;
72
74
  const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}/section`, {
73
75
  method: 'POST',
74
76
  headers: {
@@ -79,18 +81,20 @@ async function docSectionEdit(mode, reference, opts) {
79
81
  });
80
82
  if (!res.ok) {
81
83
  const text = await res.text();
82
- let message = text;
83
- try {
84
- message = JSON.parse(text).error ?? text;
85
- }
86
- catch {
87
- // non-JSON error body — print as-is
88
- }
84
+ const message = (0, api_1.extractErrorMessage)(text);
89
85
  if (res.status === 409) {
90
86
  console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
91
87
  console.error(`Hint: re-read the section (lumo doc show ${reference}${opts.section ? ` --section "${opts.section}"` : ''}), rebase your edit on it, then retry with --if-revision <current>.`);
92
88
  return 1;
93
89
  }
90
+ if (res.status === 422) {
91
+ // Structure guard rejection (LUM-410): the replacement drops structure
92
+ // the addressed section has (the message names each category, old→new).
93
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
94
+ console.error(`Hint: if the deletion is intentional, re-run with --allow-shrink. ` +
95
+ `Otherwise re-read the section (lumo doc show ${reference}${opts.section ? ` --section "${opts.section}"` : ''}) and rebase your edit on it.`);
96
+ return 1;
97
+ }
94
98
  console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(message)}`);
95
99
  return 1;
96
100
  }
@@ -82,6 +82,8 @@ async function docUpdate(reference, opts) {
82
82
  }
83
83
  body.ifRevision = Number(opts.ifRevision);
84
84
  }
85
+ if (opts.allowShrink)
86
+ body.allowShrink = true;
85
87
  // Resolve tag refs into ids
86
88
  const deps = { apiUrl, token: creds.token };
87
89
  let tagIds;
@@ -114,8 +116,10 @@ async function docUpdate(reference, opts) {
114
116
  body.addTagIds = addTagIds;
115
117
  if (removeTagIds !== undefined)
116
118
  body.removeTagIds = removeTagIds;
117
- // --if-revision is a precondition, not a field — alone it's not an update.
118
- if (Object.keys(body).filter(k => k !== 'ifRevision').length === 0) {
119
+ // --if-revision / --allow-shrink are modifiers, not fields — alone they
120
+ // are not an update.
121
+ if (Object.keys(body).filter(k => k !== 'ifRevision' && k !== 'allowShrink')
122
+ .length === 0) {
119
123
  console.error('Error: no fields to update — provide at least one flag');
120
124
  return 1;
121
125
  }
@@ -130,19 +134,22 @@ async function docUpdate(reference, opts) {
130
134
  });
131
135
  if (!res.ok) {
132
136
  const text = await res.text();
137
+ const message = (0, api_1.extractErrorMessage)(text);
133
138
  if (res.status === 409 && opts.ifRevision !== undefined) {
134
- let message = text;
135
- try {
136
- message = JSON.parse(text).error ?? text;
137
- }
138
- catch {
139
- // non-JSON error body — print as-is
140
- }
141
139
  console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
142
140
  console.error(`Hint: re-read the doc (lumo doc show ${reference} --raw), rebase your edit ` +
143
141
  `on the current source, then retry with --if-revision <current>.`);
144
142
  return 1;
145
143
  }
144
+ if (res.status === 422) {
145
+ // Structure guard rejection (LUM-410): the new body drops structure
146
+ // the stored body has (the message names each category, old→new).
147
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
148
+ console.error(`Hint: if the deletion is intentional, re-run with --allow-shrink. ` +
149
+ `Otherwise re-read the current source (lumo doc show ${reference} --raw) ` +
150
+ `and rebase your edit on it.`);
151
+ return 1;
152
+ }
146
153
  console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
147
154
  return 1;
148
155
  }
@@ -256,7 +256,7 @@ task
256
256
  task
257
257
  .command('list')
258
258
  .description('List tasks assigned to you. Filter with --status, --project, --milestone, --limit.')
259
- .option('-s, --status <value>', 'Filter by status: todo | in_progress | in_review | done')
259
+ .option('-s, --status <value>', 'Filter by status: todo | in_progress | in_review | done (case-insensitive)')
260
260
  .option('-p, --project <ref>', 'Filter by project name (case-insensitive)')
261
261
  .option('-n, --limit <count>', 'Show only the first N tasks')
262
262
  .option('-m, --milestone <ref>', 'Filter by milestone name or UUID (scopes to my tasks under that milestone)')
@@ -278,7 +278,7 @@ task
278
278
  .command('create <title>')
279
279
  .description('Create a task. --project required when workspace has >1 project; defaults: priority=low, assignee=me.')
280
280
  .option('-d, --description <text>', 'Task description')
281
- .option('-p, --priority <level>', 'Priority: low | medium | high | urgent (default: low)')
281
+ .option('-p, --priority <level>', 'Priority: low | medium | high | urgent (case-insensitive; default: low)')
282
282
  .option('-a, --assignee <ref>', 'Assignee email, name, or "me" (default: me)')
283
283
  .option('--project <ref>', 'Project name or slug')
284
284
  .option('--milestone <ref>', 'Milestone name (case-insensitive)')
@@ -377,7 +377,7 @@ const taskMemory = task
377
377
  taskMemory
378
378
  .command('list [task]')
379
379
  .description("List a task's memories. <task> defaults to the session-bound task.")
380
- .option('--category <cat>', 'Filter by trap|decision|convention|procedural')
380
+ .option('--category <cat>', 'Filter by trap|decision|convention|procedural (case-insensitive)')
381
381
  .option('-n, --limit <count>', 'Cap output to the first N rows')
382
382
  .action(wrap((taskArg, opts) => (0, memory_task_list_1.memoryTaskList)(taskArg, opts)));
383
383
  taskMemory
@@ -385,7 +385,7 @@ taskMemory
385
385
  .description('Record a memory on a task (<task> defaults to the bound session). ' +
386
386
  'Record only knowledge invisible in the codebase (the why, a runtime gotcha, a non-obvious failure, a non-trivial workflow); skip routine work. ' +
387
387
  'Pick --category then its fields.')
388
- .requiredOption('--category <cat>', 'trap | decision | convention | procedural')
388
+ .requiredOption('--category <cat>', 'trap | decision | convention | procedural (case-insensitive)')
389
389
  .option('--trigger <text>', 'trap/procedural: the situation that triggers it')
390
390
  .option('--outcome <text>', 'trap: what goes wrong')
391
391
  .option('--workaround <text>', 'trap: optional fix')
@@ -397,7 +397,7 @@ taskMemory
397
397
  .option('--applies <text>', 'convention: where the rule applies')
398
398
  .option('--workflow <text>', 'procedural: the workflow name')
399
399
  .option('--step <text>', 'procedural: a step (repeatable)', collect, [])
400
- .option('--agent <agent>', 'Producing agent: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (default claude-code)')
400
+ .option('--agent <agent>', 'Producing agent: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (case-insensitive; default claude-code)')
401
401
  .action(wrap((taskArg, opts) => (0, memory_task_add_1.memoryTaskAdd)(taskArg, opts)));
402
402
  const taskCriteria = task
403
403
  .command('criteria')
@@ -407,7 +407,7 @@ taskCriteria
407
407
  .description('Submit the whole acceptance contract from a JSON file. Default = initial agent draft (locked once submitted); --human records a HUMAN_EDIT revision (desired final list; items with "id" keep/update, missing ones are deleted).')
408
408
  .requiredOption('--file <path>', 'JSON array of criteria: [{"statement","verifierType":"MACHINE"|"HUMAN","checkpointer?","evidenceRequired?","id?"}]')
409
409
  .option('--human', 'Record a human contract revision (HUMAN_EDIT) transcribed from the conversation, with session provenance')
410
- .option('--cause <tag>', 'Why the contract drifted (with --human): NEW_INFO | SCOPE_CHANGE | DRAFT_BLIND_SPOT | GRANULARITY | OTHER')
410
+ .option('--cause <tag>', 'Why the contract drifted (with --human): NEW_INFO | SCOPE_CHANGE | DRAFT_BLIND_SPOT | GRANULARITY | OTHER (case-insensitive)')
411
411
  .action(wrap((taskId, options) => (0, task_criteria_set_1.taskCriteriaSet)(taskId, options)));
412
412
  taskCriteria
413
413
  .command('list <task>')
@@ -423,7 +423,7 @@ taskArtifact
423
423
  .requiredOption('--title <title>', 'Artifact title')
424
424
  .requiredOption('--file <path>', 'File whose contents become the artifact body')
425
425
  .requiredOption('--source <source>', 'Spec-gen framework, formal name e.g. Superpowers | "Spec Kit" | BMad | OpenSpec | GSD (opaque; quote names with spaces)')
426
- .requiredOption('--agent <agent>', 'Coding tool that produced the artifact: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf')
426
+ .requiredOption('--agent <agent>', 'Coding tool that produced the artifact: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (case-insensitive)')
427
427
  .action(wrap((taskId, options) => (0, task_artifact_add_1.taskArtifactAdd)(taskId, options)));
428
428
  taskArtifact
429
429
  .command('update <task> <artifact-id>')
@@ -431,7 +431,7 @@ taskArtifact
431
431
  .option('--kind <kind>', 'New artifact kind (opaque)')
432
432
  .option('--title <title>', 'New artifact title')
433
433
  .option('--source <source>', 'New spec-gen framework, formal name e.g. Superpowers | "Spec Kit" | BMad | OpenSpec | GSD (quote names with spaces)')
434
- .option('--agent <agent>', 'New coding tool: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf')
434
+ .option('--agent <agent>', 'New coding tool: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (case-insensitive)')
435
435
  .action(wrap((taskId, artifactId, options) => (0, task_artifact_update_1.taskArtifactUpdate)(taskId, artifactId, options)));
436
436
  taskArtifact
437
437
  .command('list <task>')
@@ -459,13 +459,13 @@ const projectMemory = projectCmd
459
459
  projectMemory
460
460
  .command('list [project]')
461
461
  .description("List a project's PROJECT-scope memories. <project> defaults to the bound task's project.")
462
- .option('--category <cat>', 'Filter by trap|decision|convention|procedural')
462
+ .option('--category <cat>', 'Filter by trap|decision|convention|procedural (case-insensitive)')
463
463
  .option('-n, --limit <count>', 'Cap output to the first N rows')
464
464
  .action(wrap((p, opts) => (0, memory_project_list_1.memoryProjectList)(p, opts)));
465
465
  projectMemory
466
466
  .command('add [project]')
467
467
  .description("Record a PROJECT-scope memory. Use PROJECT scope only when the lesson helps any task in the project. <project> defaults to the bound task's project.")
468
- .requiredOption('--category <cat>', 'trap | decision | convention | procedural')
468
+ .requiredOption('--category <cat>', 'trap | decision | convention | procedural (case-insensitive)')
469
469
  .option('--trigger <text>', 'trap/procedural trigger')
470
470
  .option('--outcome <text>', 'trap outcome')
471
471
  .option('--workaround <text>', 'trap optional workaround')
@@ -477,7 +477,7 @@ projectMemory
477
477
  .option('--applies <text>', 'convention: where it applies')
478
478
  .option('--workflow <text>', 'procedural workflow')
479
479
  .option('--step <text>', 'procedural step (repeatable)', collect, [])
480
- .option('--agent <agent>', 'Producing agent: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (default claude-code)')
480
+ .option('--agent <agent>', 'Producing agent: claude-code | codex | cursor | gemini-cli | github-copilot | windsurf (case-insensitive; default claude-code)')
481
481
  .action(wrap((p, opts) => (0, memory_project_add_1.memoryProjectAdd)(p, opts)));
482
482
  const memoryCmd = program
483
483
  .command('memory')
@@ -521,7 +521,7 @@ milestoneCmd
521
521
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
522
522
  .option('-n, --name <text>', 'New name')
523
523
  .option('-d, --description <text>', 'New description (empty string to clear)')
524
- .option('-s, --status <value>', 'New status: planned | active | completed | cancelled')
524
+ .option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
525
525
  .option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
526
526
  .option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
527
527
  .action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
@@ -641,7 +641,7 @@ doc
641
641
  .description('Create a new document. Body comes from --content, --file, or piped stdin (pick one). --scope defaults to personal. Use --task LUM-N to bind the new doc to a task, --parent to file it under another doc.')
642
642
  .option('--content <text>', 'Inline markdown content')
643
643
  .option('--file <path>', 'Read markdown content from file')
644
- .option('--scope <scope>', 'personal | workspace (default: personal)')
644
+ .option('--scope <scope>', 'personal | workspace (case-insensitive; default: personal)')
645
645
  .option('--project <ref>', 'Project name or slug to file under')
646
646
  .option('--task <LUM-N>', 'Bind to this task after creation')
647
647
  .option('--parent <doc>', 'File under this parent doc (cuid or title)')
@@ -651,7 +651,7 @@ doc
651
651
  doc
652
652
  .command('import-gdoc <url>')
653
653
  .description('Import a Google Doc as a Lumo doc (one-way Google → Lumo)')
654
- .option('--scope <scope>', 'personal | workspace (default: personal)')
654
+ .option('--scope <scope>', 'personal | workspace (case-insensitive; default: personal)')
655
655
  .option('--task <LUM-N>', 'Bind the imported doc to this task')
656
656
  .action(wrap((url, opts) => (0, doc_import_gdoc_1.docImportGdoc)(url, opts)));
657
657
  doc
@@ -660,11 +660,11 @@ doc
660
660
  .action(wrap(ref => (0, doc_sync_1.docSync)(ref)));
661
661
  doc
662
662
  .command('update <doc>')
663
- .description('Update an existing document. <doc> accepts a cuid or a case-insensitive title (ambiguous titles fail with candidates). Replacement body comes from --content, --file, or piped stdin (pick one). --tag/--tag-id (bulk replace) cannot be combined with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id.')
663
+ .description('Update an existing document. <doc> accepts a cuid or a case-insensitive title (ambiguous titles fail with candidates). Replacement body comes from --content, --file, or piped stdin (pick one). A body update that drops tables/rows/headings versus the stored body is rejected with 422 (structure guard) unless --allow-shrink is passed. --tag/--tag-id (bulk replace) cannot be combined with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id.')
664
664
  .option('--title <text>', 'New title')
665
665
  .option('--content <text>', 'Replace content (inline)')
666
666
  .option('--file <path>', 'Replace content from file')
667
- .option('--scope <scope>', 'personal | workspace')
667
+ .option('--scope <scope>', 'personal | workspace (case-insensitive)')
668
668
  .option('--project <ref>', 'Project name/slug; pass "" to clear')
669
669
  .option('--tag <name>', 'Set tags by name (bulk replace, repeatable)', collect, [])
670
670
  .option('--tag-id <cuid>', 'Set tags by id (bulk replace, repeatable)', collect, [])
@@ -673,6 +673,7 @@ doc
673
673
  .option('--remove-tag <name>', 'Remove tag by name (repeatable). Unknown names are find-or-create, so prefer --remove-tag-id to avoid orphan rows.', collect, [])
674
674
  .option('--remove-tag-id <cuid>', 'Remove tag by id (repeatable). Unknown ids are a no-op.', collect, [])
675
675
  .option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show); mismatch fails with a 409 conflict')
676
+ .option('--allow-shrink', 'Let the update through even when it drops tables/rows/headings versus the stored body (default: rejected with 422)')
676
677
  .action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
677
678
  doc
678
679
  .command('show <doc>')
@@ -682,11 +683,12 @@ doc
682
683
  .action(wrap((reference, opts) => (0, doc_show_1.docShow)(reference, opts)));
683
684
  doc
684
685
  .command('patch <doc>')
685
- .description('Replace one heading-addressed section of the markdown source (heading line included), leaving every byte outside the section untouched. Requires the doc to have a stored markdown source. Commits conditionally on the body revision: a concurrent edit fails with 409 instead of being overwritten.')
686
+ .description('Replace one heading-addressed section of the markdown source (heading line included), leaving every byte outside the section untouched. Requires the doc to have a stored markdown source. Commits conditionally on the body revision: a concurrent edit fails with 409 instead of being overwritten. A replacement that drops tables/rows/headings within the addressed section is rejected with 422 (structure guard) unless --allow-shrink is passed.')
686
687
  .requiredOption('--section <heading>', 'Heading of the section to replace (prefix with #… to pin depth)')
687
688
  .option('--content <text>', 'New section content (inline markdown)')
688
689
  .option('--file <path>', 'New section content from file')
689
690
  .option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
691
+ .option('--allow-shrink', 'Let the patch through even when it drops tables/rows/headings within the addressed section (default: rejected with 422)')
690
692
  .action(wrap((reference, opts) => (0, doc_section_edit_1.docPatch)(reference, opts)));
691
693
  doc
692
694
  .command('append <doc>')
@@ -704,7 +706,7 @@ doc
704
706
  doc
705
707
  .command('list')
706
708
  .description('List documents visible to the current user')
707
- .option('--scope <scope>', 'personal | workspace | all (default: all)')
709
+ .option('--scope <scope>', 'personal | workspace | all (case-insensitive; default: all)')
708
710
  .option('--project <ref>', 'Filter by project name/slug')
709
711
  .option('--task <LUM-N>', 'Only docs bound to this task')
710
712
  .option('--limit <n>', 'Cap output to first N rows')
@@ -732,7 +734,7 @@ doc
732
734
  doc
733
735
  .command('share <doc> <member>')
734
736
  .description('Share a document with a workspace member. PRIVATE docs are auto-promoted to SHARED. <member> accepts "me", an email, or a display name.')
735
- .option('--role <role>', 'viewer | editor | manager (default: viewer)')
737
+ .option('--role <role>', 'viewer | editor | manager (case-insensitive; default: viewer)')
736
738
  .action(wrap((docRef, member, opts) => (0, doc_share_1.docShare)(docRef, member, opts)));
737
739
  doc
738
740
  .command('unshare <doc> <member>')
@@ -747,8 +749,8 @@ task
747
749
  .description('Update a task. Provide at least one of --title, --description, --status, --priority, --assignee, --milestone, --sprint, or tag flags. Use "" to clear description, assignee, milestone, or sprint binding. --tag/--tag-id (bulk replace) cannot be combined with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id.')
748
750
  .option('-t, --title <text>', 'New title')
749
751
  .option('-d, --description <text>', 'New description (empty string to clear)')
750
- .option('-s, --status <value>', 'New status: todo | in_progress | in_review | done')
751
- .option('-p, --priority <level>', 'New priority: low | medium | high | urgent')
752
+ .option('-s, --status <value>', 'New status: todo | in_progress | in_review | done (case-insensitive)')
753
+ .option('-p, --priority <level>', 'New priority: low | medium | high | urgent (case-insensitive)')
752
754
  .option('-a, --assignee <ref>', 'New assignee: email, name, or "me" (empty string to clear)')
753
755
  .option('--milestone <ref>', 'Milestone name (case-insensitive); empty string to unbind')
754
756
  .option('--sprint <ref>', 'Sprint number or UUID to bind the task to; empty string to unbind from current sprint')
@@ -5,6 +5,7 @@ exports.hostMismatchWarning = hostMismatchWarning;
5
5
  exports.resolveAuthedApiUrl = resolveAuthedApiUrl;
6
6
  exports.resolveApiUrl = resolveApiUrl;
7
7
  exports.trimTrailingSlash = trimTrailingSlash;
8
+ exports.extractErrorMessage = extractErrorMessage;
8
9
  exports.verifyToken = verifyToken;
9
10
  const DEFAULT_API_URL = 'https://www.uselumo.ai';
10
11
  // Hostnames allowed to use plaintext http:// — local dev only. Everything
@@ -91,6 +92,18 @@ function resolveApiUrl() {
91
92
  function trimTrailingSlash(url) {
92
93
  return url.replace(/\/+$/, '');
93
94
  }
95
+ /**
96
+ * Pull the `error` field out of a JSON API error body; non-JSON bodies (or
97
+ * JSON without an `error` string) fall back to the raw text unchanged.
98
+ */
99
+ function extractErrorMessage(text) {
100
+ try {
101
+ return JSON.parse(text).error ?? text;
102
+ }
103
+ catch {
104
+ return text;
105
+ }
106
+ }
94
107
  async function verifyToken(apiUrl, token) {
95
108
  let res;
96
109
  try {
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * Structural tag counting over rendered document HTML.
4
+ *
5
+ * Single source of truth shared by the server-side structure guard on the
6
+ * document update path (LUM-410) and `scripts/verify-live-doc.ts`, so the
7
+ * guard and the script can never drift on what counts as "structure".
8
+ *
9
+ * Counting is regex-based over the rendered HTML. That is safe here because
10
+ * both inputs always come out of the same `markdownToHtml` renderer (or the
11
+ * Tiptap editor's serializer), which emits plain lowercase tags — this is
12
+ * not a general-purpose HTML parser.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.TAG_PATTERNS = void 0;
16
+ exports.countPattern = countPattern;
17
+ exports.compareStructure = compareStructure;
18
+ exports.hardMismatches = hardMismatches;
19
+ exports.softMismatches = softMismatches;
20
+ exports.structureShrinks = structureShrinks;
21
+ exports.formatStructureShrinks = formatStructureShrinks;
22
+ /** Categories `verify-live-doc.ts` reports on (hard = table structure). */
23
+ exports.TAG_PATTERNS = [
24
+ { tag: 'table', pattern: /<table/g, hard: true },
25
+ { tag: 'tr', pattern: /<tr/g, hard: true },
26
+ { tag: 'h1', pattern: /<h1/g, hard: false },
27
+ { tag: 'h2', pattern: /<h2/g, hard: false },
28
+ { tag: 'h3', pattern: /<h3/g, hard: false },
29
+ { tag: 'li', pattern: /<li/g, hard: false },
30
+ { tag: 'a', pattern: /<a[\s>]/g, hard: false },
31
+ { tag: 'strong', pattern: /<strong/g, hard: false },
32
+ { tag: 'code', pattern: /<code/g, hard: false },
33
+ { tag: 'blockquote', pattern: /<blockquote/g, hard: false },
34
+ ];
35
+ function countPattern(html, pattern) {
36
+ return (html.match(pattern) ?? []).length;
37
+ }
38
+ function compareStructure(renderedHtml, storedHtml) {
39
+ return exports.TAG_PATTERNS.map(({ tag, pattern, hard }) => ({
40
+ tag,
41
+ rendered: countPattern(renderedHtml, pattern),
42
+ stored: countPattern(storedHtml, pattern),
43
+ hard,
44
+ }));
45
+ }
46
+ function hardMismatches(checks) {
47
+ return checks.filter(c => c.hard && c.rendered !== c.stored);
48
+ }
49
+ function softMismatches(checks) {
50
+ return checks.filter(c => !c.hard && c.rendered !== c.stored);
51
+ }
52
+ /**
53
+ * Categories the update guard protects. Headings are counted as one
54
+ * aggregate bucket (h1–h6) so promoting/demoting a heading level is not a
55
+ * loss — only dropping a section heading outright is.
56
+ */
57
+ const GUARD_PATTERNS = [
58
+ { tag: 'table', pattern: /<table/g },
59
+ { tag: 'tr', pattern: /<tr/g },
60
+ { tag: 'heading', pattern: /<h[1-6][\s>]/g },
61
+ ];
62
+ /**
63
+ * Compare two rendered HTML bodies and report every guarded category whose
64
+ * count shrank. Empty result = the edit loses no guarded structure.
65
+ */
66
+ function structureShrinks(oldHtml, newHtml) {
67
+ const shrinks = [];
68
+ for (const { tag, pattern } of GUARD_PATTERNS) {
69
+ const before = countPattern(oldHtml, pattern);
70
+ const after = countPattern(newHtml, pattern);
71
+ if (after < before)
72
+ shrinks.push({ tag, before, after });
73
+ }
74
+ return shrinks;
75
+ }
76
+ /** "table 3→0, tr 12→0" — the per-category loss detail for error messages. */
77
+ function formatStructureShrinks(shrinks) {
78
+ return shrinks.map(s => `${s.tag} ${s.before}→${s.after}`).join(', ');
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",