@lumoai/cli 1.28.0 → 1.29.1
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 +4 -1
- package/assets/skill/references/docs.md +73 -7
- package/dist/cli/src/commands/doc-list.js +2 -2
- package/dist/cli/src/commands/doc-section-edit.js +113 -0
- package/dist/cli/src/commands/doc-show.js +48 -1
- package/dist/cli/src/commands/doc-update.js +22 -1
- package/dist/cli/src/index.js +39 -20
- package/dist/cli/src/lib/markdown-sections.js +12 -0
- package/dist/shared/src/markdown-sections.js +162 -0
- package/package.json +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -97,8 +97,11 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
97
97
|
|
|
98
98
|
**Documents** — see [docs.md](references/docs.md)
|
|
99
99
|
|
|
100
|
-
- `lumo doc create/update/show/list/delete` — document CRUD
|
|
100
|
+
- `lumo doc create/update/show/list/delete` — document CRUD; `doc show` prints the body revision, `doc update` takes `--if-revision <n>` (mismatch → 409 conflict, re-read and retry — no silent overwrite)
|
|
101
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 show <doc> --section "<heading>"` — print just one heading-addressed section of the markdown source (byte-faithful slice, subsections included; revision on stderr; prefix `#…` pins depth)
|
|
103
|
+
- `lumo doc patch <doc> --section "<heading>" --content/--file/stdin [--if-revision N]` — replace ONLY that section (heading line included); every byte outside it is untouched; concurrent edits 409 instead of clobbering
|
|
104
|
+
- `lumo doc append <doc> [--section "<heading>"] --content/--file/stdin [--if-revision N]` — append at section end (or doc end without `--section`); pure insertion, no existing byte modified — the safest write for running logs/ledgers
|
|
102
105
|
- `lumo doc diff <doc> --file <local.md>` — compare the server-side markdown source against a local file (exit 0 identical, 1 with unified diff)
|
|
103
106
|
- `lumo doc move` — reparent under a parent / to root
|
|
104
107
|
- `lumo doc bind/unbind <doc> <task>` — task linkage
|
|
@@ -64,6 +64,8 @@ The `Tags:` line is omitted when no tags were attached.
|
|
|
64
64
|
| `--remove-tag <name>` | string (repeatable) | Detach tag by name. `--remove-tag <name>` resolves the name via find-or-create on the workspace. If the name was unknown, a new Tag row is created (orphan, no attachments) before the detach runs as a no-op. Use `--remove-tag-id <cuid>` to avoid orphans. Max 20. |
|
|
65
65
|
| `--remove-tag-id <cuid>` | string (repeatable) | Detach tag by id. Unknown ids are a no-op (no side effects). Max 20. |
|
|
66
66
|
|
|
67
|
+
Optimistic concurrency (LUM-409): `--if-revision <n>` only applies the update if the doc body is still at revision `n` (from `doc show`). Mismatch → 409 conflict, nothing written — re-read, rebase, retry. `--if-revision` alone is not an update (still errors "no fields to update").
|
|
68
|
+
|
|
67
69
|
`--tag` / `--tag-id` (bulk replace) are mutually exclusive with `--add-tag` / `--add-tag-id` / `--remove-tag` / `--remove-tag-id`. The CLI errors before any network call if both families are mixed.
|
|
68
70
|
|
|
69
71
|
Like `doc create`, `--file` is sandboxed: the CLI rejects paths that resolve outside the project directory or match the sensitive-file denylist (`.env*`, private keys, `credentials`, …). No override flag.
|
|
@@ -82,16 +84,19 @@ lumo doc update RFC --tag final --add-tag oops
|
|
|
82
84
|
|
|
83
85
|
### When to suggest `doc update`
|
|
84
86
|
|
|
85
|
-
- User wants to revise an existing doc.
|
|
87
|
+
- User wants to revise an existing doc **as a whole** (title, scope, or a full-body rewrite).
|
|
86
88
|
- After running `lumo doc list` or `doc show`, if the user wants to change a doc's scope, title, or content.
|
|
89
|
+
- For a small edit inside one section, prefer `doc patch` / `doc append` (below) — full `doc update` has the maximum clobber radius.
|
|
90
|
+
- When replacing the body from a previously fetched base, pass `--if-revision` so a concurrent edit fails loudly (409) instead of being overwritten.
|
|
87
91
|
|
|
88
|
-
### `lumo doc show <doc> [--raw]` — print one document's detail
|
|
92
|
+
### `lumo doc show <doc> [--raw | --section <heading>]` — print one document's detail
|
|
89
93
|
|
|
90
|
-
Default mode prints a key:value header (id, title, scope, project, created/updated timestamps, mentioned tasks) and the content rendered back to markdown.
|
|
94
|
+
Default mode prints a key:value header (id, title, scope, project, created/updated timestamps, **revision**, mentioned tasks) and the content rendered back to markdown. `Revision:` is the body's optimistic-concurrency counter — feed it back as `--if-revision` on `doc update` / `doc patch` / `doc append`.
|
|
91
95
|
|
|
92
96
|
```bash
|
|
93
97
|
lumo doc show "RFC: doc CLI"
|
|
94
|
-
lumo doc show cmd_xxx --raw > base.md
|
|
98
|
+
lumo doc show cmd_xxx --raw > base.md # byte-identical edit base (revision on stderr)
|
|
99
|
+
lumo doc show cmd_xxx --section "D 状态表" > sec.md # one section only (revision on stderr)
|
|
95
100
|
```
|
|
96
101
|
|
|
97
102
|
**`--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.
|
|
@@ -102,13 +107,74 @@ lumo doc show cmd_xxx --raw > base.md # byte-identical edit base
|
|
|
102
107
|
|
|
103
108
|
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
109
|
|
|
105
|
-
|
|
110
|
+
**`--section <heading>` (LUM-409)** prints just one heading-addressed section of the markdown source — a byte-faithful slice from the heading line through (not including) the next same-or-higher-level heading, subsections included. No header on stdout (the slice is a legal `doc patch` base); the current revision is printed to **stderr** as `Revision: N`. Mutually exclusive with `--raw`.
|
|
111
|
+
|
|
112
|
+
- Section addressing: pass the heading text (`--section "D 状态表"`), case-insensitive fallback after an exact pass. Prefix with `#…` to pin the level when the same text exists at several depths (`--section "## Status"`).
|
|
113
|
+
- Missing heading → exit 1 listing the available headings; ambiguous heading → exit 1 with a depth-disambiguation hint.
|
|
114
|
+
- Requires a stored markdown source — same no-fallback rule and rebuild flow as `--raw`.
|
|
115
|
+
- Heading detection is markdown-aware: `#` lines inside fenced code blocks or blockquotes are never section boundaries.
|
|
116
|
+
|
|
117
|
+
Use default mode when the user wants to read a doc; use `--raw` whenever the full output will be edited and uploaded back; use `--section` when only one part matters (keeps the context window small and the patch radius smaller).
|
|
106
118
|
|
|
107
|
-
### When to suggest `doc show --raw`
|
|
119
|
+
### When to suggest `doc show --raw` / `--section`
|
|
108
120
|
|
|
109
121
|
- Before any `doc update --file` that starts from existing remote content — fetch the base with `--raw`, never from rendered `doc show` output.
|
|
122
|
+
- Before a `doc patch` — read the section with `--section` first, note the `Revision:` line, edit, patch back with `--if-revision`.
|
|
110
123
|
- User asks "what's the exact source of this doc", or an agent needs a faithful local copy of a live doc.
|
|
111
|
-
-
|
|
124
|
+
- User asks "what's in section X" of a long doc — `--section` avoids pulling the whole body into context.
|
|
125
|
+
- If `--raw`/`--section` errors (no stored source), walk the rebuild flow above instead of editing the rendered output.
|
|
126
|
+
|
|
127
|
+
### `lumo doc patch <doc> --section <heading>` — replace one section
|
|
128
|
+
|
|
129
|
+
Replaces the **whole addressed section** (heading line included, subsections included) with the provided content, server-side, leaving every byte outside the section untouched. The new content comes from `--content`, `--file`, or piped stdin (one required; `--file` is sandboxed like `doc update`).
|
|
130
|
+
|
|
131
|
+
| Flag | Type | Notes |
|
|
132
|
+
| --------------------- | ------ | --------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `--section <heading>` | string | Required. Heading text addressing the section; prefix `#…` pins the depth. |
|
|
134
|
+
| `--content <text>` | string | New section content (include the heading line — the whole section is replaced verbatim). |
|
|
135
|
+
| `--file <path>` | string | New section content from file (project-local sandbox). |
|
|
136
|
+
| `--if-revision <n>` | int | Only apply if the body is still at revision `n`. Recommended whenever the edit base was read earlier. |
|
|
137
|
+
|
|
138
|
+
Concurrency: the splice always commits **conditionally** on the revision the server read the source at — even without `--if-revision`, a concurrent body edit between read and write returns 409 instead of clobbering. On 409 the CLI prints the server reason plus a re-read-and-retry hint and exits 1.
|
|
139
|
+
|
|
140
|
+
Requires a stored markdown source (same rule as `--raw`); errors with the rebuild hint otherwise. Replacement is verbatim — if the new content omits the heading line, the heading is gone (that is an explicit choice, not a merge).
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
lumo doc show cmd_xxx --section "D 状态表" > sec.md # stderr: Revision: 6
|
|
144
|
+
# … edit sec.md …
|
|
145
|
+
lumo doc patch cmd_xxx --section "D 状态表" --file sec.md --if-revision 6
|
|
146
|
+
# → Patched cmd_xxx "登记册" § "D 状态表" revision 7
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### When to suggest `doc patch`
|
|
150
|
+
|
|
151
|
+
- User wants to change one section of a live doc ("update the status table", "rewrite section D") — patch beats full `doc update` on clobber radius and context size.
|
|
152
|
+
- Live-doc status updates that used to be read-whole/edit/upload-whole round-trips.
|
|
153
|
+
- If the patch 409s: re-read the section, rebase the edit, retry — never fall back to a full-body upload from the stale base.
|
|
154
|
+
|
|
155
|
+
### `lumo doc append <doc> [--section <heading>]` — append to a section (or the doc)
|
|
156
|
+
|
|
157
|
+
Inserts the new content at the **end of the addressed section** (just before the next same-or-higher-level heading), or at the end of the document when `--section` is omitted. Pure insertion: no pre-existing byte is modified, which makes it the natural write for running logs, ledgers and queues. Content channels and sandbox are the same as `doc patch`; separator blank lines are added automatically.
|
|
158
|
+
|
|
159
|
+
| Flag | Type | Notes |
|
|
160
|
+
| --------------------- | ------ | --------------------------------------------------------------------------- |
|
|
161
|
+
| `--section <heading>` | string | Optional. Omit to append at the document end. |
|
|
162
|
+
| `--content <text>` | string | Content to append. |
|
|
163
|
+
| `--file <path>` | string | Content from file (project-local sandbox). |
|
|
164
|
+
| `--if-revision <n>` | int | Only apply if the body is still at revision `n`; 409 + retry hint otherwise. |
|
|
165
|
+
|
|
166
|
+
Same concurrency contract as `doc patch` (always a conditional commit; 409 on conflict). Requires a stored markdown source.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
lumo doc append cmd_xxx --section "F 待办队列" --content "- [ ] 评估 XYZ 论文"
|
|
170
|
+
# → Appended to cmd_xxx "登记册" § "F 待办队列" revision 8
|
|
171
|
+
echo "## 2026-06-10\n吸收了 3 篇" | lumo doc append cmd_xxx # document end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### When to suggest `doc append`
|
|
175
|
+
|
|
176
|
+
- User wants to add an entry/row/log line to a section ("把 X 加进待办队列", "append today's notes") — this is the killer op for ledger-style live docs: zero clobber risk by construction.
|
|
177
|
+
- Whenever the alternative would be re-uploading the whole doc just to add lines at the end of one section.
|
|
112
178
|
|
|
113
179
|
### `lumo doc diff <doc> --file <local.md>` — compare remote markdown source vs a local file
|
|
114
180
|
|
|
@@ -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);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatSectionEditLine = formatSectionEditLine;
|
|
4
|
+
exports.docPatch = docPatch;
|
|
5
|
+
exports.docAppend = docAppend;
|
|
6
|
+
const config_1 = require("../lib/config");
|
|
7
|
+
const api_1 = require("../lib/api");
|
|
8
|
+
const doc_input_1 = require("../lib/doc-input");
|
|
9
|
+
const resolve_doc_id_1 = require("../lib/resolve-doc-id");
|
|
10
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
11
|
+
function formatSectionEditLine(args) {
|
|
12
|
+
const escaped = (0, sanitize_1.sanitizeField)(args.title).replace(/"/g, '\\"');
|
|
13
|
+
const target = args.sectionHeading
|
|
14
|
+
? ` § "${(0, sanitize_1.sanitizeField)(args.sectionHeading)}"`
|
|
15
|
+
: ' (document end)';
|
|
16
|
+
const rev = args.revision !== null ? ` revision ${args.revision}` : '';
|
|
17
|
+
return `${args.verb} ${args.id} "${escaped}"${target}${rev}`;
|
|
18
|
+
}
|
|
19
|
+
async function docSectionEdit(mode, reference, opts) {
|
|
20
|
+
const cmd = mode === 'replace' ? 'patch' : 'append';
|
|
21
|
+
if (!reference) {
|
|
22
|
+
console.error(`Error: missing <doc>. Usage: lumo doc ${cmd} <doc> ${mode === 'replace' ? '--section <heading>' : '[--section <heading>]'} [--content <md> | --file <path> | stdin] [--if-revision <n>]`);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
if (mode === 'replace' && (!opts.section || opts.section.trim() === '')) {
|
|
26
|
+
console.error('Error: --section <heading> is required for doc patch');
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
let ifRevision;
|
|
30
|
+
if (opts.ifRevision !== undefined) {
|
|
31
|
+
if (!/^\d+$/.test(opts.ifRevision)) {
|
|
32
|
+
console.error(`Error: --if-revision must be a non-negative integer (got "${opts.ifRevision}")`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
ifRevision = Number(opts.ifRevision);
|
|
36
|
+
}
|
|
37
|
+
const content = await (0, doc_input_1.resolveDocContent)({
|
|
38
|
+
content: opts.content,
|
|
39
|
+
file: opts.file,
|
|
40
|
+
stdinIsTTY: Boolean(process.stdin.isTTY),
|
|
41
|
+
readStdin: doc_input_1.readStdinToString,
|
|
42
|
+
readFile: doc_input_1.readFileUtf8,
|
|
43
|
+
});
|
|
44
|
+
if (content.kind === 'error') {
|
|
45
|
+
console.error(`Error: ${content.message}`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (content.kind === 'none') {
|
|
49
|
+
console.error(`Error: doc ${cmd} needs content — pass --content, --file, or pipe stdin`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const creds = (0, config_1.readCredentials)();
|
|
53
|
+
if (!creds) {
|
|
54
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
58
|
+
const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
|
|
59
|
+
if (!id) {
|
|
60
|
+
console.error(`Error: Document not found: ${reference}`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const body = {
|
|
64
|
+
mode,
|
|
65
|
+
contentMarkdown: content.markdown,
|
|
66
|
+
};
|
|
67
|
+
if (opts.section !== undefined && opts.section.trim() !== '') {
|
|
68
|
+
body.section = opts.section;
|
|
69
|
+
}
|
|
70
|
+
if (ifRevision !== undefined)
|
|
71
|
+
body.ifRevision = ifRevision;
|
|
72
|
+
const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}/section`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${creds.token}`,
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
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
|
+
}
|
|
89
|
+
if (res.status === 409) {
|
|
90
|
+
console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
91
|
+
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
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const { document } = (await res.json());
|
|
98
|
+
console.log(formatSectionEditLine({
|
|
99
|
+
verb: mode === 'replace' ? 'Patched' : 'Appended to',
|
|
100
|
+
id: document.id,
|
|
101
|
+
title: document.title,
|
|
102
|
+
sectionHeading: document.sectionHeading ?? opts.section ?? null,
|
|
103
|
+
revision: typeof document.contentRevision === 'number'
|
|
104
|
+
? document.contentRevision
|
|
105
|
+
: null,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
function docPatch(reference, opts) {
|
|
109
|
+
return docSectionEdit('replace', reference, opts);
|
|
110
|
+
}
|
|
111
|
+
function docAppend(reference, opts) {
|
|
112
|
+
return docSectionEdit('append', reference, opts);
|
|
113
|
+
}
|
|
@@ -5,6 +5,7 @@ exports.docShow = docShow;
|
|
|
5
5
|
const config_1 = require("../lib/config");
|
|
6
6
|
const api_1 = require("../lib/api");
|
|
7
7
|
const markdown_tiptap_1 = require("../lib/markdown-tiptap");
|
|
8
|
+
const markdown_sections_1 = require("../lib/markdown-sections");
|
|
8
9
|
const resolve_doc_id_1 = require("../lib/resolve-doc-id");
|
|
9
10
|
const sanitize_1 = require("../lib/sanitize");
|
|
10
11
|
function scopeLabel(s) {
|
|
@@ -22,6 +23,7 @@ function formatShowOutput(vm) {
|
|
|
22
23
|
`Project: ${vm.projectName ? (0, sanitize_1.sanitizeField)(vm.projectName) : '-'}`,
|
|
23
24
|
`Created: ${vm.createdAt}`,
|
|
24
25
|
`Updated: ${vm.updatedAt}`,
|
|
26
|
+
`Revision: ${vm.revision ?? '-'}`,
|
|
25
27
|
`Mentioned tasks: ${vm.mentionedTasks.length ? vm.mentionedTasks.join(', ') : '-'}`,
|
|
26
28
|
];
|
|
27
29
|
const header = lines.join('\n');
|
|
@@ -29,7 +31,11 @@ function formatShowOutput(vm) {
|
|
|
29
31
|
}
|
|
30
32
|
async function docShow(reference, opts = {}) {
|
|
31
33
|
if (!reference) {
|
|
32
|
-
console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw]');
|
|
34
|
+
console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw | --section <heading>]');
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (opts.raw && opts.section !== undefined) {
|
|
38
|
+
console.error('Error: --raw and --section are mutually exclusive (--raw prints the whole source)');
|
|
33
39
|
return 1;
|
|
34
40
|
}
|
|
35
41
|
const creds = (0, config_1.readCredentials)();
|
|
@@ -73,6 +79,46 @@ async function docShow(reference, opts = {}) {
|
|
|
73
79
|
return 1;
|
|
74
80
|
}
|
|
75
81
|
process.stdout.write(d.sourceMarkdown);
|
|
82
|
+
// Revision goes to stderr so stdout stays a byte-pure edit base while
|
|
83
|
+
// the agent still sees the number to pass back as --if-revision.
|
|
84
|
+
if (typeof d.contentRevision === 'number') {
|
|
85
|
+
console.error(`Revision: ${d.contentRevision}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (opts.section !== undefined) {
|
|
90
|
+
// Section reads slice the stored markdown source — same no-fallback rule
|
|
91
|
+
// as --raw: a rendered-output slice would not be a legal edit base.
|
|
92
|
+
if (typeof d.sourceMarkdown !== 'string') {
|
|
93
|
+
console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
|
|
94
|
+
`or predates markdown source storage), so --section cannot slice it. ` +
|
|
95
|
+
`Rebuild a base first: run \`lumo doc show ${d.id}\`, reconstruct the markdown ` +
|
|
96
|
+
`faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\`.`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const match = (0, markdown_sections_1.findSection)(d.sourceMarkdown, opts.section);
|
|
100
|
+
if (match.kind === 'not-found') {
|
|
101
|
+
const list = match.available
|
|
102
|
+
.slice(0, 30)
|
|
103
|
+
.map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)}`)
|
|
104
|
+
.join('\n');
|
|
105
|
+
console.error(`Error: section not found: "${opts.section}". Available headings:\n${list || ' (none)'}`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
if (match.kind === 'ambiguous') {
|
|
109
|
+
const list = match.candidates
|
|
110
|
+
.map(s => ` ${'#'.repeat(s.depth)} ${(0, sanitize_1.sanitizeField)(s.heading)} (line ${s.line + 1})`)
|
|
111
|
+
.join('\n');
|
|
112
|
+
console.error(`Error: section "${opts.section}" is ambiguous — ${match.candidates.length} headings match:\n${list}\n` +
|
|
113
|
+
`Disambiguate by depth, e.g. --section "${'#'.repeat(match.candidates[0].depth)} ${match.candidates[0].heading}"`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
// Byte-faithful slice, verbatim on stdout (legal patch base); revision on
|
|
117
|
+
// stderr for the follow-up `doc patch --if-revision`.
|
|
118
|
+
process.stdout.write((0, markdown_sections_1.extractSection)(d.sourceMarkdown, match.section));
|
|
119
|
+
if (typeof d.contentRevision === 'number') {
|
|
120
|
+
console.error(`Revision: ${d.contentRevision}`);
|
|
121
|
+
}
|
|
76
122
|
return;
|
|
77
123
|
}
|
|
78
124
|
// Server returns `contentMarkdown` derived from the HTML body (LUM-83+).
|
|
@@ -110,6 +156,7 @@ async function docShow(reference, opts = {}) {
|
|
|
110
156
|
projectName: d.project?.name ?? null,
|
|
111
157
|
createdAt: d.createdAt ?? '',
|
|
112
158
|
updatedAt: d.updatedAt ?? '',
|
|
159
|
+
revision: typeof d.contentRevision === 'number' ? d.contentRevision : null,
|
|
113
160
|
mentionedTasks,
|
|
114
161
|
bodyMarkdown: body,
|
|
115
162
|
}));
|
|
@@ -75,6 +75,13 @@ async function docUpdate(reference, opts) {
|
|
|
75
75
|
}
|
|
76
76
|
if (content.kind === 'ok')
|
|
77
77
|
body.contentMarkdown = content.markdown;
|
|
78
|
+
if (opts.ifRevision !== undefined) {
|
|
79
|
+
if (!/^\d+$/.test(opts.ifRevision)) {
|
|
80
|
+
console.error(`Error: --if-revision must be a non-negative integer (got "${opts.ifRevision}")`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
body.ifRevision = Number(opts.ifRevision);
|
|
84
|
+
}
|
|
78
85
|
// Resolve tag refs into ids
|
|
79
86
|
const deps = { apiUrl, token: creds.token };
|
|
80
87
|
let tagIds;
|
|
@@ -107,7 +114,8 @@ async function docUpdate(reference, opts) {
|
|
|
107
114
|
body.addTagIds = addTagIds;
|
|
108
115
|
if (removeTagIds !== undefined)
|
|
109
116
|
body.removeTagIds = removeTagIds;
|
|
110
|
-
if
|
|
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) {
|
|
111
119
|
console.error('Error: no fields to update — provide at least one flag');
|
|
112
120
|
return 1;
|
|
113
121
|
}
|
|
@@ -122,6 +130,19 @@ async function docUpdate(reference, opts) {
|
|
|
122
130
|
});
|
|
123
131
|
if (!res.ok) {
|
|
124
132
|
const text = await res.text();
|
|
133
|
+
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
|
+
console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
142
|
+
console.error(`Hint: re-read the doc (lumo doc show ${reference} --raw), rebase your edit ` +
|
|
143
|
+
`on the current source, then retry with --if-revision <current>.`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
125
146
|
console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
|
|
126
147
|
return 1;
|
|
127
148
|
}
|
package/dist/cli/src/index.js
CHANGED
|
@@ -109,6 +109,7 @@ const doc_sync_1 = require("./commands/doc-sync");
|
|
|
109
109
|
const doc_update_1 = require("./commands/doc-update");
|
|
110
110
|
const doc_show_1 = require("./commands/doc-show");
|
|
111
111
|
const doc_diff_1 = require("./commands/doc-diff");
|
|
112
|
+
const doc_section_edit_1 = require("./commands/doc-section-edit");
|
|
112
113
|
const doc_list_1 = require("./commands/doc-list");
|
|
113
114
|
const doc_delete_1 = require("./commands/doc-delete");
|
|
114
115
|
const doc_bind_1 = require("./commands/doc-bind");
|
|
@@ -255,7 +256,7 @@ task
|
|
|
255
256
|
task
|
|
256
257
|
.command('list')
|
|
257
258
|
.description('List tasks assigned to you. Filter with --status, --project, --milestone, --limit.')
|
|
258
|
-
.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)')
|
|
259
260
|
.option('-p, --project <ref>', 'Filter by project name (case-insensitive)')
|
|
260
261
|
.option('-n, --limit <count>', 'Show only the first N tasks')
|
|
261
262
|
.option('-m, --milestone <ref>', 'Filter by milestone name or UUID (scopes to my tasks under that milestone)')
|
|
@@ -277,7 +278,7 @@ task
|
|
|
277
278
|
.command('create <title>')
|
|
278
279
|
.description('Create a task. --project required when workspace has >1 project; defaults: priority=low, assignee=me.')
|
|
279
280
|
.option('-d, --description <text>', 'Task description')
|
|
280
|
-
.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)')
|
|
281
282
|
.option('-a, --assignee <ref>', 'Assignee email, name, or "me" (default: me)')
|
|
282
283
|
.option('--project <ref>', 'Project name or slug')
|
|
283
284
|
.option('--milestone <ref>', 'Milestone name (case-insensitive)')
|
|
@@ -376,7 +377,7 @@ const taskMemory = task
|
|
|
376
377
|
taskMemory
|
|
377
378
|
.command('list [task]')
|
|
378
379
|
.description("List a task's memories. <task> defaults to the session-bound task.")
|
|
379
|
-
.option('--category <cat>', 'Filter by trap|decision|convention|procedural')
|
|
380
|
+
.option('--category <cat>', 'Filter by trap|decision|convention|procedural (case-insensitive)')
|
|
380
381
|
.option('-n, --limit <count>', 'Cap output to the first N rows')
|
|
381
382
|
.action(wrap((taskArg, opts) => (0, memory_task_list_1.memoryTaskList)(taskArg, opts)));
|
|
382
383
|
taskMemory
|
|
@@ -384,7 +385,7 @@ taskMemory
|
|
|
384
385
|
.description('Record a memory on a task (<task> defaults to the bound session). ' +
|
|
385
386
|
'Record only knowledge invisible in the codebase (the why, a runtime gotcha, a non-obvious failure, a non-trivial workflow); skip routine work. ' +
|
|
386
387
|
'Pick --category then its fields.')
|
|
387
|
-
.requiredOption('--category <cat>', 'trap | decision | convention | procedural')
|
|
388
|
+
.requiredOption('--category <cat>', 'trap | decision | convention | procedural (case-insensitive)')
|
|
388
389
|
.option('--trigger <text>', 'trap/procedural: the situation that triggers it')
|
|
389
390
|
.option('--outcome <text>', 'trap: what goes wrong')
|
|
390
391
|
.option('--workaround <text>', 'trap: optional fix')
|
|
@@ -396,7 +397,7 @@ taskMemory
|
|
|
396
397
|
.option('--applies <text>', 'convention: where the rule applies')
|
|
397
398
|
.option('--workflow <text>', 'procedural: the workflow name')
|
|
398
399
|
.option('--step <text>', 'procedural: a step (repeatable)', collect, [])
|
|
399
|
-
.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)')
|
|
400
401
|
.action(wrap((taskArg, opts) => (0, memory_task_add_1.memoryTaskAdd)(taskArg, opts)));
|
|
401
402
|
const taskCriteria = task
|
|
402
403
|
.command('criteria')
|
|
@@ -406,7 +407,7 @@ taskCriteria
|
|
|
406
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).')
|
|
407
408
|
.requiredOption('--file <path>', 'JSON array of criteria: [{"statement","verifierType":"MACHINE"|"HUMAN","checkpointer?","evidenceRequired?","id?"}]')
|
|
408
409
|
.option('--human', 'Record a human contract revision (HUMAN_EDIT) transcribed from the conversation, with session provenance')
|
|
409
|
-
.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)')
|
|
410
411
|
.action(wrap((taskId, options) => (0, task_criteria_set_1.taskCriteriaSet)(taskId, options)));
|
|
411
412
|
taskCriteria
|
|
412
413
|
.command('list <task>')
|
|
@@ -422,7 +423,7 @@ taskArtifact
|
|
|
422
423
|
.requiredOption('--title <title>', 'Artifact title')
|
|
423
424
|
.requiredOption('--file <path>', 'File whose contents become the artifact body')
|
|
424
425
|
.requiredOption('--source <source>', 'Spec-gen framework, formal name e.g. Superpowers | "Spec Kit" | BMad | OpenSpec | GSD (opaque; quote names with spaces)')
|
|
425
|
-
.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)')
|
|
426
427
|
.action(wrap((taskId, options) => (0, task_artifact_add_1.taskArtifactAdd)(taskId, options)));
|
|
427
428
|
taskArtifact
|
|
428
429
|
.command('update <task> <artifact-id>')
|
|
@@ -430,7 +431,7 @@ taskArtifact
|
|
|
430
431
|
.option('--kind <kind>', 'New artifact kind (opaque)')
|
|
431
432
|
.option('--title <title>', 'New artifact title')
|
|
432
433
|
.option('--source <source>', 'New spec-gen framework, formal name e.g. Superpowers | "Spec Kit" | BMad | OpenSpec | GSD (quote names with spaces)')
|
|
433
|
-
.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)')
|
|
434
435
|
.action(wrap((taskId, artifactId, options) => (0, task_artifact_update_1.taskArtifactUpdate)(taskId, artifactId, options)));
|
|
435
436
|
taskArtifact
|
|
436
437
|
.command('list <task>')
|
|
@@ -458,13 +459,13 @@ const projectMemory = projectCmd
|
|
|
458
459
|
projectMemory
|
|
459
460
|
.command('list [project]')
|
|
460
461
|
.description("List a project's PROJECT-scope memories. <project> defaults to the bound task's project.")
|
|
461
|
-
.option('--category <cat>', 'Filter by trap|decision|convention|procedural')
|
|
462
|
+
.option('--category <cat>', 'Filter by trap|decision|convention|procedural (case-insensitive)')
|
|
462
463
|
.option('-n, --limit <count>', 'Cap output to the first N rows')
|
|
463
464
|
.action(wrap((p, opts) => (0, memory_project_list_1.memoryProjectList)(p, opts)));
|
|
464
465
|
projectMemory
|
|
465
466
|
.command('add [project]')
|
|
466
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.")
|
|
467
|
-
.requiredOption('--category <cat>', 'trap | decision | convention | procedural')
|
|
468
|
+
.requiredOption('--category <cat>', 'trap | decision | convention | procedural (case-insensitive)')
|
|
468
469
|
.option('--trigger <text>', 'trap/procedural trigger')
|
|
469
470
|
.option('--outcome <text>', 'trap outcome')
|
|
470
471
|
.option('--workaround <text>', 'trap optional workaround')
|
|
@@ -476,7 +477,7 @@ projectMemory
|
|
|
476
477
|
.option('--applies <text>', 'convention: where it applies')
|
|
477
478
|
.option('--workflow <text>', 'procedural workflow')
|
|
478
479
|
.option('--step <text>', 'procedural step (repeatable)', collect, [])
|
|
479
|
-
.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)')
|
|
480
481
|
.action(wrap((p, opts) => (0, memory_project_add_1.memoryProjectAdd)(p, opts)));
|
|
481
482
|
const memoryCmd = program
|
|
482
483
|
.command('memory')
|
|
@@ -520,7 +521,7 @@ milestoneCmd
|
|
|
520
521
|
.option('--project <ref>', 'Project name or slug (when identifier is a name)')
|
|
521
522
|
.option('-n, --name <text>', 'New name')
|
|
522
523
|
.option('-d, --description <text>', 'New description (empty string to clear)')
|
|
523
|
-
.option('-s, --status <value>', 'New status: planned | active | completed | cancelled')
|
|
524
|
+
.option('-s, --status <value>', 'New status: planned | active | completed | cancelled (case-insensitive)')
|
|
524
525
|
.option('--start <date>', 'Start date YYYY-MM-DD (empty string to clear)')
|
|
525
526
|
.option('--target <date>', 'Target date YYYY-MM-DD (empty string to clear)')
|
|
526
527
|
.action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
|
|
@@ -640,7 +641,7 @@ doc
|
|
|
640
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.')
|
|
641
642
|
.option('--content <text>', 'Inline markdown content')
|
|
642
643
|
.option('--file <path>', 'Read markdown content from file')
|
|
643
|
-
.option('--scope <scope>', 'personal | workspace (default: personal)')
|
|
644
|
+
.option('--scope <scope>', 'personal | workspace (case-insensitive; default: personal)')
|
|
644
645
|
.option('--project <ref>', 'Project name or slug to file under')
|
|
645
646
|
.option('--task <LUM-N>', 'Bind to this task after creation')
|
|
646
647
|
.option('--parent <doc>', 'File under this parent doc (cuid or title)')
|
|
@@ -650,7 +651,7 @@ doc
|
|
|
650
651
|
doc
|
|
651
652
|
.command('import-gdoc <url>')
|
|
652
653
|
.description('Import a Google Doc as a Lumo doc (one-way Google → Lumo)')
|
|
653
|
-
.option('--scope <scope>', 'personal | workspace (default: personal)')
|
|
654
|
+
.option('--scope <scope>', 'personal | workspace (case-insensitive; default: personal)')
|
|
654
655
|
.option('--task <LUM-N>', 'Bind the imported doc to this task')
|
|
655
656
|
.action(wrap((url, opts) => (0, doc_import_gdoc_1.docImportGdoc)(url, opts)));
|
|
656
657
|
doc
|
|
@@ -663,7 +664,7 @@ doc
|
|
|
663
664
|
.option('--title <text>', 'New title')
|
|
664
665
|
.option('--content <text>', 'Replace content (inline)')
|
|
665
666
|
.option('--file <path>', 'Replace content from file')
|
|
666
|
-
.option('--scope <scope>', 'personal | workspace')
|
|
667
|
+
.option('--scope <scope>', 'personal | workspace (case-insensitive)')
|
|
667
668
|
.option('--project <ref>', 'Project name/slug; pass "" to clear')
|
|
668
669
|
.option('--tag <name>', 'Set tags by name (bulk replace, repeatable)', collect, [])
|
|
669
670
|
.option('--tag-id <cuid>', 'Set tags by id (bulk replace, repeatable)', collect, [])
|
|
@@ -671,12 +672,30 @@ doc
|
|
|
671
672
|
.option('--add-tag-id <cuid>', 'Add tag by id (repeatable)', collect, [])
|
|
672
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, [])
|
|
673
674
|
.option('--remove-tag-id <cuid>', 'Remove tag by id (repeatable). Unknown ids are a no-op.', collect, [])
|
|
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')
|
|
674
676
|
.action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
|
|
675
677
|
doc
|
|
676
678
|
.command('show <doc>')
|
|
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.')
|
|
679
|
+
.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. --section <heading> prints just that section of the markdown source (revision goes to stderr).')
|
|
678
680
|
.option('--raw', 'Print the stored markdown source verbatim (no header); errors if absent')
|
|
681
|
+
.option('--section <heading>', 'Print one heading-addressed section of the markdown source (prefix with #… to pin depth, e.g. "## Status")')
|
|
679
682
|
.action(wrap((reference, opts) => (0, doc_show_1.docShow)(reference, opts)));
|
|
683
|
+
doc
|
|
684
|
+
.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
|
+
.requiredOption('--section <heading>', 'Heading of the section to replace (prefix with #… to pin depth)')
|
|
687
|
+
.option('--content <text>', 'New section content (inline markdown)')
|
|
688
|
+
.option('--file <path>', 'New section content from file')
|
|
689
|
+
.option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
|
|
690
|
+
.action(wrap((reference, opts) => (0, doc_section_edit_1.docPatch)(reference, opts)));
|
|
691
|
+
doc
|
|
692
|
+
.command('append <doc>')
|
|
693
|
+
.description('Append markdown at the end of a heading-addressed section (before the next same-or-higher heading), or at the document end when --section is omitted. Pure insertion — no existing byte is modified — which makes it the safest write for running logs/ledgers. 409 on concurrent edits.')
|
|
694
|
+
.option('--section <heading>', 'Heading of the section to append into (omit to append at the document end)')
|
|
695
|
+
.option('--content <text>', 'Content to append (inline markdown)')
|
|
696
|
+
.option('--file <path>', 'Content to append from file')
|
|
697
|
+
.option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
|
|
698
|
+
.action(wrap((reference, opts) => (0, doc_section_edit_1.docAppend)(reference, opts)));
|
|
680
699
|
doc
|
|
681
700
|
.command('diff <doc>')
|
|
682
701
|
.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.')
|
|
@@ -685,7 +704,7 @@ doc
|
|
|
685
704
|
doc
|
|
686
705
|
.command('list')
|
|
687
706
|
.description('List documents visible to the current user')
|
|
688
|
-
.option('--scope <scope>', 'personal | workspace | all (default: all)')
|
|
707
|
+
.option('--scope <scope>', 'personal | workspace | all (case-insensitive; default: all)')
|
|
689
708
|
.option('--project <ref>', 'Filter by project name/slug')
|
|
690
709
|
.option('--task <LUM-N>', 'Only docs bound to this task')
|
|
691
710
|
.option('--limit <n>', 'Cap output to first N rows')
|
|
@@ -713,7 +732,7 @@ doc
|
|
|
713
732
|
doc
|
|
714
733
|
.command('share <doc> <member>')
|
|
715
734
|
.description('Share a document with a workspace member. PRIVATE docs are auto-promoted to SHARED. <member> accepts "me", an email, or a display name.')
|
|
716
|
-
.option('--role <role>', 'viewer | editor | manager (default: viewer)')
|
|
735
|
+
.option('--role <role>', 'viewer | editor | manager (case-insensitive; default: viewer)')
|
|
717
736
|
.action(wrap((docRef, member, opts) => (0, doc_share_1.docShare)(docRef, member, opts)));
|
|
718
737
|
doc
|
|
719
738
|
.command('unshare <doc> <member>')
|
|
@@ -728,8 +747,8 @@ task
|
|
|
728
747
|
.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.')
|
|
729
748
|
.option('-t, --title <text>', 'New title')
|
|
730
749
|
.option('-d, --description <text>', 'New description (empty string to clear)')
|
|
731
|
-
.option('-s, --status <value>', 'New status: todo | in_progress | in_review | done')
|
|
732
|
-
.option('-p, --priority <level>', 'New priority: low | medium | high | urgent')
|
|
750
|
+
.option('-s, --status <value>', 'New status: todo | in_progress | in_review | done (case-insensitive)')
|
|
751
|
+
.option('-p, --priority <level>', 'New priority: low | medium | high | urgent (case-insensitive)')
|
|
733
752
|
.option('-a, --assignee <ref>', 'New assignee: email, name, or "me" (empty string to clear)')
|
|
734
753
|
.option('--milestone <ref>', 'Milestone name (case-insensitive); empty string to unbind')
|
|
735
754
|
.option('--sprint <ref>', 'Sprint number or UUID to bind the task to; empty string to unbind from current sprint')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseSectionQuery = exports.appendToSection = exports.replaceSection = exports.extractSection = exports.findSection = exports.listSections = void 0;
|
|
4
|
+
// Single source of truth lives in shared/src/markdown-sections.ts (LUM-409) —
|
|
5
|
+
// the CLI must slice sections exactly like the server does.
|
|
6
|
+
var markdown_sections_1 = require("../../../shared/src/markdown-sections");
|
|
7
|
+
Object.defineProperty(exports, "listSections", { enumerable: true, get: function () { return markdown_sections_1.listSections; } });
|
|
8
|
+
Object.defineProperty(exports, "findSection", { enumerable: true, get: function () { return markdown_sections_1.findSection; } });
|
|
9
|
+
Object.defineProperty(exports, "extractSection", { enumerable: true, get: function () { return markdown_sections_1.extractSection; } });
|
|
10
|
+
Object.defineProperty(exports, "replaceSection", { enumerable: true, get: function () { return markdown_sections_1.replaceSection; } });
|
|
11
|
+
Object.defineProperty(exports, "appendToSection", { enumerable: true, get: function () { return markdown_sections_1.appendToSection; } });
|
|
12
|
+
Object.defineProperty(exports, "parseSectionQuery", { enumerable: true, get: function () { return markdown_sections_1.parseSectionQuery; } });
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.listSections = listSections;
|
|
7
|
+
exports.parseSectionQuery = parseSectionQuery;
|
|
8
|
+
exports.findSection = findSection;
|
|
9
|
+
exports.extractSection = extractSection;
|
|
10
|
+
exports.replaceSection = replaceSection;
|
|
11
|
+
exports.appendToSection = appendToSection;
|
|
12
|
+
const markdown_it_1 = __importDefault(require("markdown-it"));
|
|
13
|
+
/**
|
|
14
|
+
* Heading-addressed section slicing over a markdown source string (LUM-409).
|
|
15
|
+
*
|
|
16
|
+
* Single source of truth shared by the documents service (server-side
|
|
17
|
+
* section patch/append) and the CLI (`doc show --section`) so both sides
|
|
18
|
+
* agree byte-for-byte on where a section starts and ends.
|
|
19
|
+
*
|
|
20
|
+
* All offsets are character offsets into the ORIGINAL source string — slices
|
|
21
|
+
* are taken with `src.slice(...)`, never re-serialized, so everything
|
|
22
|
+
* outside an edited section survives byte-identical. Headings are located
|
|
23
|
+
* with markdown-it block tokens (not a regex) so `#` lines inside fenced
|
|
24
|
+
* code blocks or blockquotes are never mistaken for section boundaries.
|
|
25
|
+
*/
|
|
26
|
+
const md = new markdown_it_1.default({ html: false, linkify: true, breaks: false });
|
|
27
|
+
/**
|
|
28
|
+
* Start offsets of each line, splitting on the same newline shapes
|
|
29
|
+
* markdown-it normalizes before tokenizing (`\r\n` / `\r` / `\n`), so a
|
|
30
|
+
* token's `map` line number indexes this array directly.
|
|
31
|
+
*/
|
|
32
|
+
function computeLineStarts(src) {
|
|
33
|
+
const starts = [0];
|
|
34
|
+
for (let i = 0; i < src.length; i++) {
|
|
35
|
+
const ch = src.charCodeAt(i);
|
|
36
|
+
if (ch === 13 /* \r */) {
|
|
37
|
+
if (src.charCodeAt(i + 1) === 10 /* \n */)
|
|
38
|
+
i++;
|
|
39
|
+
starts.push(i + 1);
|
|
40
|
+
}
|
|
41
|
+
else if (ch === 10 /* \n */) {
|
|
42
|
+
starts.push(i + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return starts;
|
|
46
|
+
}
|
|
47
|
+
/** List every top-level heading-addressed section of a markdown source. */
|
|
48
|
+
function listSections(src) {
|
|
49
|
+
if (!src)
|
|
50
|
+
return [];
|
|
51
|
+
const tokens = md.parse(src, {});
|
|
52
|
+
const lineStarts = computeLineStarts(src);
|
|
53
|
+
const heads = [];
|
|
54
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
55
|
+
const t = tokens[i];
|
|
56
|
+
// level === 0 keeps headings nested inside blockquotes/lists from
|
|
57
|
+
// becoming section boundaries.
|
|
58
|
+
if (t?.type === 'heading_open' && t.map && t.level === 0) {
|
|
59
|
+
const inline = tokens[i + 1];
|
|
60
|
+
heads.push({
|
|
61
|
+
depth: Number(t.tag.slice(1)),
|
|
62
|
+
text: (inline?.type === 'inline' ? inline.content : '').trim(),
|
|
63
|
+
line: t.map[0],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return heads.map((h, idx) => {
|
|
68
|
+
let end = src.length;
|
|
69
|
+
for (let j = idx + 1; j < heads.length; j++) {
|
|
70
|
+
const next = heads[j];
|
|
71
|
+
if (next && next.depth <= h.depth) {
|
|
72
|
+
end = lineStarts[next.line] ?? src.length;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
heading: h.text,
|
|
78
|
+
depth: h.depth,
|
|
79
|
+
line: h.line,
|
|
80
|
+
startOffset: lineStarts[h.line] ?? 0,
|
|
81
|
+
endOffset: end,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse a `--section` query. A leading `#`-run constrains the heading depth
|
|
87
|
+
* (`"## Status"` only matches h2 headings titled "Status"), which is how a
|
|
88
|
+
* caller disambiguates same-text headings at different levels.
|
|
89
|
+
*/
|
|
90
|
+
function parseSectionQuery(raw) {
|
|
91
|
+
const m = /^(#{1,6})\s+(.*)$/.exec(raw.trim());
|
|
92
|
+
if (m)
|
|
93
|
+
return { text: (m[2] ?? '').trim(), depth: (m[1] ?? '').length };
|
|
94
|
+
return { text: raw.trim() };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Locate a section by heading text. Exact match first, then a
|
|
98
|
+
* case-insensitive pass; more than one hit in the winning pass is reported
|
|
99
|
+
* as ambiguous rather than silently picking one.
|
|
100
|
+
*/
|
|
101
|
+
function findSection(src, query) {
|
|
102
|
+
const q = parseSectionQuery(query);
|
|
103
|
+
const sections = listSections(src);
|
|
104
|
+
const pool = q.depth !== undefined ? sections.filter(s => s.depth === q.depth) : sections;
|
|
105
|
+
let matches = pool.filter(s => s.heading === q.text);
|
|
106
|
+
if (matches.length === 0) {
|
|
107
|
+
const lower = q.text.toLowerCase();
|
|
108
|
+
matches = pool.filter(s => s.heading.toLowerCase() === lower);
|
|
109
|
+
}
|
|
110
|
+
const first = matches[0];
|
|
111
|
+
if (matches.length === 1 && first)
|
|
112
|
+
return { kind: 'found', section: first };
|
|
113
|
+
if (matches.length === 0)
|
|
114
|
+
return { kind: 'not-found', available: sections };
|
|
115
|
+
return { kind: 'ambiguous', candidates: matches };
|
|
116
|
+
}
|
|
117
|
+
/** Byte-faithful slice of one section (heading line through section end). */
|
|
118
|
+
function extractSection(src, section) {
|
|
119
|
+
return src.slice(section.startOffset, section.endOffset);
|
|
120
|
+
}
|
|
121
|
+
function stripOuterNewlines(text) {
|
|
122
|
+
return text.replace(/^[\r\n]+/, '').replace(/[\r\n]+$/, '');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Replace one section (heading included) with `newText`, verbatim. Bytes
|
|
126
|
+
* before `startOffset` and from `endOffset` on are carried over untouched;
|
|
127
|
+
* only the new block's trailing newlines are padded so a following heading
|
|
128
|
+
* still starts at the beginning of a line with a blank line before it.
|
|
129
|
+
*/
|
|
130
|
+
function replaceSection(src, section, newText) {
|
|
131
|
+
const before = src.slice(0, section.startOffset);
|
|
132
|
+
const after = src.slice(section.endOffset);
|
|
133
|
+
let block = newText;
|
|
134
|
+
if (after.length > 0 && block.length > 0 && !block.endsWith('\n\n')) {
|
|
135
|
+
block = block.replace(/\n*$/, '\n\n');
|
|
136
|
+
}
|
|
137
|
+
return before + block + after;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Insert `text` at the end of a section (or at the end of the document when
|
|
141
|
+
* `section` is null). Pure insertion: every pre-existing byte is preserved;
|
|
142
|
+
* only separator newlines around the new chunk are added as needed.
|
|
143
|
+
*/
|
|
144
|
+
function appendToSection(src, section, text) {
|
|
145
|
+
const insertAt = section ? section.endOffset : src.length;
|
|
146
|
+
const before = src.slice(0, insertAt);
|
|
147
|
+
const after = src.slice(insertAt);
|
|
148
|
+
const chunk = stripOuterNewlines(text);
|
|
149
|
+
if (chunk.length === 0)
|
|
150
|
+
return src;
|
|
151
|
+
let lead = '';
|
|
152
|
+
if (before.length > 0) {
|
|
153
|
+
if (before.endsWith('\n\n'))
|
|
154
|
+
lead = '';
|
|
155
|
+
else if (before.endsWith('\n') || before.endsWith('\r'))
|
|
156
|
+
lead = '\n';
|
|
157
|
+
else
|
|
158
|
+
lead = '\n\n';
|
|
159
|
+
}
|
|
160
|
+
const tail = after.length > 0 ? '\n\n' : '\n';
|
|
161
|
+
return before + lead + chunk + tail + after;
|
|
162
|
+
}
|