@lumoai/cli 1.27.0 → 1.29.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/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 +34 -7
- package/dist/cli/src/lib/git-task.js +9 -17
- package/dist/cli/src/lib/markdown-sections.js +12 -0
- package/dist/shared/src/markdown-sections.js +162 -0
- package/dist/shared/src/task-identifier.js +51 -0
- package/package.json +1 -1
- package/assets/skill/SKILL.md +0 -160
- package/assets/skill/references/artifacts-figma.md +0 -124
- package/assets/skill/references/criteria.md +0 -139
- package/assets/skill/references/docs.md +0 -339
- package/assets/skill/references/memory.md +0 -103
- package/assets/skill/references/milestones.md +0 -244
- package/assets/skill/references/onboarding.md +0 -102
- package/assets/skill/references/sessions.md +0 -222
- package/assets/skill/references/sprints.md +0 -157
- package/assets/skill/references/task-context.md +0 -136
- package/assets/skill/references/tasks.md +0 -357
- package/assets/skill/references/verify.md +0 -124
|
@@ -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
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.program = void 0;
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const os = __importStar(require("os"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
@@ -108,6 +109,7 @@ const doc_sync_1 = require("./commands/doc-sync");
|
|
|
108
109
|
const doc_update_1 = require("./commands/doc-update");
|
|
109
110
|
const doc_show_1 = require("./commands/doc-show");
|
|
110
111
|
const doc_diff_1 = require("./commands/doc-diff");
|
|
112
|
+
const doc_section_edit_1 = require("./commands/doc-section-edit");
|
|
111
113
|
const doc_list_1 = require("./commands/doc-list");
|
|
112
114
|
const doc_delete_1 = require("./commands/doc-delete");
|
|
113
115
|
const doc_bind_1 = require("./commands/doc-bind");
|
|
@@ -123,9 +125,16 @@ const worktree_rm_1 = require("./commands/worktree-rm");
|
|
|
123
125
|
const worktree_list_1 = require("./commands/worktree-list");
|
|
124
126
|
const update_check_1 = require("./lib/update-check");
|
|
125
127
|
const sanitize_1 = require("./lib/sanitize");
|
|
128
|
+
// Introspection mode: tooling (scripts/analysis grammar extraction) imports
|
|
129
|
+
// this module to walk the registered commander tree without running the CLI.
|
|
130
|
+
// Skips package.json resolution (which assumes the compiled dist/ layout),
|
|
131
|
+
// update checks, local-state cleanup, and parseAsync. Never set for real runs.
|
|
132
|
+
const isIntrospect = process.env.LUMO_CLI_INTROSPECT === '1';
|
|
126
133
|
// Resolve package.json relative to __dirname so this works regardless of how
|
|
127
134
|
// deep the compiled output ends up (flat dist/ or nested dist/cli/src/).
|
|
128
|
-
const pkg =
|
|
135
|
+
const pkg = isIntrospect
|
|
136
|
+
? { name: '@lumoai/cli', version: '0.0.0-introspect' }
|
|
137
|
+
: require(path.resolve(__dirname, '../../..', 'package.json'));
|
|
129
138
|
// Detached background-refresh worker: re-entry point for the spawn() in
|
|
130
139
|
// maybeRefreshInBackground(). Fetches latest, writes cache, exits — skipping
|
|
131
140
|
// commander.parseAsync() below.
|
|
@@ -135,7 +144,7 @@ if (isUpdateCheckWorker) {
|
|
|
135
144
|
.catch(() => { })
|
|
136
145
|
.finally(() => process.exit(0));
|
|
137
146
|
}
|
|
138
|
-
else {
|
|
147
|
+
else if (!isIntrospect) {
|
|
139
148
|
(0, update_check_1.printUpdateNoticeIfAny)(pkg.name, pkg.version);
|
|
140
149
|
(0, update_check_1.maybeRefreshInBackground)(pkg.name);
|
|
141
150
|
}
|
|
@@ -143,8 +152,7 @@ else {
|
|
|
143
152
|
// is now server-authoritative; the legacy global pointer and the
|
|
144
153
|
// per-session sentinel directory are both obsolete. Failures are
|
|
145
154
|
// swallowed so a permission glitch doesn't prevent CLI invocation.
|
|
146
|
-
|
|
147
|
-
(() => {
|
|
155
|
+
if (!isIntrospect) {
|
|
148
156
|
const dir = process.env.LUMO_CONFIG_DIR || path.join(os.homedir(), '.lumo');
|
|
149
157
|
try {
|
|
150
158
|
fs.rmSync(path.join(dir, 'current-task.json'), { force: true });
|
|
@@ -154,7 +162,7 @@ else {
|
|
|
154
162
|
fs.rmSync(path.join(dir, 'sessions'), { recursive: true, force: true });
|
|
155
163
|
}
|
|
156
164
|
catch { }
|
|
157
|
-
}
|
|
165
|
+
}
|
|
158
166
|
const collect = (val, acc) => [...acc, val];
|
|
159
167
|
function wrap(fn) {
|
|
160
168
|
return async (...args) => {
|
|
@@ -179,6 +187,7 @@ const program = new commander_1.Command()
|
|
|
179
187
|
// created via .command() inherit these settings.
|
|
180
188
|
.showSuggestionAfterError(true)
|
|
181
189
|
.showHelpAfterError('(run the command with --help to list its valid flags and arguments)');
|
|
190
|
+
exports.program = program;
|
|
182
191
|
const auth = program.command('auth').description('Manage Lumo authentication');
|
|
183
192
|
auth
|
|
184
193
|
.command('login')
|
|
@@ -663,12 +672,30 @@ doc
|
|
|
663
672
|
.option('--add-tag-id <cuid>', 'Add tag by id (repeatable)', collect, [])
|
|
664
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, [])
|
|
665
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')
|
|
666
676
|
.action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
|
|
667
677
|
doc
|
|
668
678
|
.command('show <doc>')
|
|
669
|
-
.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).')
|
|
670
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")')
|
|
671
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)));
|
|
672
699
|
doc
|
|
673
700
|
.command('diff <doc>')
|
|
674
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.')
|
|
@@ -883,7 +910,7 @@ for (const [slug, description] of HOOK_SUBCOMMANDS) {
|
|
|
883
910
|
.option('--agent <token>', 'Coding agent that owns this session (e.g. claude-code, codex). Baked in by `lumo setup --agent`.')
|
|
884
911
|
.action(wrap((opts) => (0, hook_1.hookCommand)(slug, opts.agent)));
|
|
885
912
|
}
|
|
886
|
-
if (!isUpdateCheckWorker) {
|
|
913
|
+
if (!isUpdateCheckWorker && !isIntrospect) {
|
|
887
914
|
program.parseAsync(process.argv).catch(err => {
|
|
888
915
|
const msg = err instanceof Error ? err.message : String(err);
|
|
889
916
|
console.error(`Error: ${(0, sanitize_1.sanitizeField)(msg)}`);
|
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.matchTaskIdentifier =
|
|
3
|
+
exports.matchTaskIdentifier = void 0;
|
|
4
4
|
exports.extractTaskFromGit = extractTaskFromGit;
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
|
+
const task_identifier_1 = require("../../../shared/src/task-identifier");
|
|
7
|
+
Object.defineProperty(exports, "matchTaskIdentifier", { enumerable: true, get: function () { return task_identifier_1.matchTaskIdentifier; } });
|
|
6
8
|
/**
|
|
7
9
|
* How many recent commit subjects to scan when the branch name carries no
|
|
8
10
|
* task id (e.g. on a generic feature branch or detached HEAD).
|
|
9
11
|
*/
|
|
10
12
|
const DEFAULT_COMMIT_DEPTH = 20;
|
|
11
|
-
const TASK_RE = /LUM-(\d+)/i;
|
|
12
|
-
/**
|
|
13
|
-
* Pull the first `LUM-<n>` token out of arbitrary text (a branch name or a
|
|
14
|
-
* commit subject) and normalize it to upper case. Returns null when no task
|
|
15
|
-
* id is present. The leading `-` in the pattern means the bare word `lumo`
|
|
16
|
-
* never matches.
|
|
17
|
-
*/
|
|
18
|
-
function matchTaskIdentifier(text) {
|
|
19
|
-
const m = text.match(TASK_RE);
|
|
20
|
-
return m ? `LUM-${m[1]}` : null;
|
|
21
|
-
}
|
|
22
13
|
/**
|
|
23
14
|
* Run a git subcommand and return trimmed stdout, or '' on any failure
|
|
24
15
|
* (non-zero exit, spawn error, not a repository, timeout). Never throws.
|
|
@@ -41,17 +32,18 @@ function gitOutput(args, cwd) {
|
|
|
41
32
|
/**
|
|
42
33
|
* Infer the task to work on from local git: prefer the current branch name
|
|
43
34
|
* (e.g. `lumo/LUM-145-...`), then fall back to the most recent commit
|
|
44
|
-
* subjects (e.g. `... [LUM-145]`).
|
|
45
|
-
*
|
|
46
|
-
*
|
|
35
|
+
* subjects (e.g. `... [LUM-145]`). Any team prefix matches (SPEC-12 as much
|
|
36
|
+
* as LUM-145). Returns null when nothing matches — detached HEAD (branch
|
|
37
|
+
* reads `HEAD`), a branch with no identifier and no tagged commits, or a
|
|
38
|
+
* directory that is not a git repository all degrade to null.
|
|
47
39
|
*/
|
|
48
40
|
function extractTaskFromGit(cwd, commitDepth = DEFAULT_COMMIT_DEPTH) {
|
|
49
41
|
const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
50
|
-
const fromBranch = matchTaskIdentifier(branch);
|
|
42
|
+
const fromBranch = (0, task_identifier_1.matchTaskIdentifier)(branch);
|
|
51
43
|
if (fromBranch)
|
|
52
44
|
return { identifier: fromBranch, source: 'branch' };
|
|
53
45
|
const log = gitOutput(['log', '-n', String(commitDepth), '--format=%s'], cwd);
|
|
54
|
-
const fromLog = matchTaskIdentifier(log);
|
|
46
|
+
const fromLog = (0, task_identifier_1.matchTaskIdentifier)(log);
|
|
55
47
|
if (fromLog)
|
|
56
48
|
return { identifier: fromLog, source: 'commit' };
|
|
57
49
|
return null;
|
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TASK_IDENTIFIER_PATTERN = void 0;
|
|
4
|
+
exports.matchTaskIdentifier = matchTaskIdentifier;
|
|
5
|
+
/**
|
|
6
|
+
* Single source of truth for the task-identifier pattern (LUM-419).
|
|
7
|
+
*
|
|
8
|
+
* Consumed by both sides so they cannot drift:
|
|
9
|
+
* - server: lib/integrations/github/branch.ts (webhook branch → task linking)
|
|
10
|
+
* - CLI: cli/src/lib/git-task.ts (session-start git-suggest)
|
|
11
|
+
*
|
|
12
|
+
* A task identifier is `<TEAM>-<n>` where TEAM is the team's configured
|
|
13
|
+
* prefix (`Team.identifier`, 1–10 letters) — e.g. LUM-419, SPEC-123. The
|
|
14
|
+
* prefix is NOT hardcoded to any one team.
|
|
15
|
+
*/
|
|
16
|
+
exports.TASK_IDENTIFIER_PATTERN = String.raw `\b([A-Za-z]{1,10}-\d+)\b`;
|
|
17
|
+
/**
|
|
18
|
+
* Acronym-number tokens that match the identifier shape but are never task
|
|
19
|
+
* ids. Commit subjects routinely contain these ("fix UTF-8 handling",
|
|
20
|
+
* "use SHA-256"), so the matcher skips them instead of suggesting a bogus
|
|
21
|
+
* session attach. Compared against the uppercased prefix segment.
|
|
22
|
+
*/
|
|
23
|
+
const NON_TASK_PREFIXES = new Set([
|
|
24
|
+
'AES',
|
|
25
|
+
'CVE',
|
|
26
|
+
'GPT',
|
|
27
|
+
'HTTP',
|
|
28
|
+
'ISO',
|
|
29
|
+
'RFC',
|
|
30
|
+
'RSA',
|
|
31
|
+
'SHA',
|
|
32
|
+
'TLS',
|
|
33
|
+
'UTF',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Extract the first task identifier from arbitrary text (a branch name or a
|
|
37
|
+
* commit subject), normalized to upper case. Tokens whose prefix is a known
|
|
38
|
+
* non-task acronym (UTF-8, SHA-256, …) are skipped; later genuine matches in
|
|
39
|
+
* the same text still win. Returns null when nothing matches.
|
|
40
|
+
*/
|
|
41
|
+
function matchTaskIdentifier(text) {
|
|
42
|
+
const re = new RegExp(exports.TASK_IDENTIFIER_PATTERN, 'g');
|
|
43
|
+
for (const m of text.matchAll(re)) {
|
|
44
|
+
const identifier = m[1].toUpperCase();
|
|
45
|
+
const prefix = identifier.slice(0, identifier.lastIndexOf('-'));
|
|
46
|
+
if (NON_TASK_PREFIXES.has(prefix))
|
|
47
|
+
continue;
|
|
48
|
+
return identifier;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|