@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.
- package/assets/skill/SKILL.md +163 -0
- package/assets/skill/references/artifacts-figma.md +124 -0
- package/assets/skill/references/criteria.md +160 -0
- package/assets/skill/references/docs.md +413 -0
- package/assets/skill/references/memory.md +103 -0
- package/assets/skill/references/milestones.md +244 -0
- package/assets/skill/references/onboarding.md +102 -0
- package/assets/skill/references/sessions.md +225 -0
- package/assets/skill/references/sprints.md +157 -0
- package/assets/skill/references/task-context.md +136 -0
- package/assets/skill/references/tasks.md +357 -0
- package/assets/skill/references/verify.md +148 -0
- package/dist/cli/src/commands/doc-list.js +2 -2
- package/dist/cli/src/commands/doc-section-edit.js +11 -7
- package/dist/cli/src/commands/doc-update.js +16 -9
- package/dist/cli/src/index.js +23 -21
- package/dist/cli/src/lib/api.js +13 -0
- package/dist/shared/src/html-structure.js +79 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
118
|
-
|
|
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
|
}
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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')
|
package/dist/cli/src/lib/api.js
CHANGED
|
@@ -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
|
+
}
|