@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.
@@ -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 (Object.keys(body).length === 0) {
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
  }
@@ -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 = require(path.resolve(__dirname, '../../..', 'package.json'));
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 = matchTaskIdentifier;
3
+ exports.matchTaskIdentifier = void 0;
4
4
  exports.extractTaskFromGit = extractTaskFromGit;
5
5
  const child_process_1 = require("child_process");
6
+ const task_identifier_1 = require("../../../shared/src/task-identifier");
7
+ Object.defineProperty(exports, "matchTaskIdentifier", { enumerable: true, get: function () { return task_identifier_1.matchTaskIdentifier; } });
6
8
  /**
7
9
  * How many recent commit subjects to scan when the branch name carries no
8
10
  * task id (e.g. on a generic feature branch or detached HEAD).
9
11
  */
10
12
  const DEFAULT_COMMIT_DEPTH = 20;
11
- const TASK_RE = /LUM-(\d+)/i;
12
- /**
13
- * Pull the first `LUM-<n>` token out of arbitrary text (a branch name or a
14
- * commit subject) and normalize it to upper case. Returns null when no task
15
- * id is present. The leading `-` in the pattern means the bare word `lumo`
16
- * never matches.
17
- */
18
- function matchTaskIdentifier(text) {
19
- const m = text.match(TASK_RE);
20
- return m ? `LUM-${m[1]}` : null;
21
- }
22
13
  /**
23
14
  * Run a git subcommand and return trimmed stdout, or '' on any failure
24
15
  * (non-zero exit, spawn error, not a repository, timeout). Never throws.
@@ -41,17 +32,18 @@ function gitOutput(args, cwd) {
41
32
  /**
42
33
  * Infer the task to work on from local git: prefer the current branch name
43
34
  * (e.g. `lumo/LUM-145-...`), then fall back to the most recent commit
44
- * subjects (e.g. `... [LUM-145]`). Returns null when nothing matches
45
- * detached HEAD (branch reads `HEAD`), a non-lumo branch with no tagged
46
- * commits, or a directory that is not a git repository all degrade to null.
35
+ * subjects (e.g. `... [LUM-145]`). Any team prefix matches (SPEC-12 as much
36
+ * as LUM-145). Returns null when nothing matches detached HEAD (branch
37
+ * reads `HEAD`), a branch with no identifier and no tagged commits, or a
38
+ * directory that is not a git repository all degrade to null.
47
39
  */
48
40
  function extractTaskFromGit(cwd, commitDepth = DEFAULT_COMMIT_DEPTH) {
49
41
  const branch = gitOutput(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
50
- const fromBranch = matchTaskIdentifier(branch);
42
+ const fromBranch = (0, task_identifier_1.matchTaskIdentifier)(branch);
51
43
  if (fromBranch)
52
44
  return { identifier: fromBranch, source: 'branch' };
53
45
  const log = gitOutput(['log', '-n', String(commitDepth), '--format=%s'], cwd);
54
- const fromLog = matchTaskIdentifier(log);
46
+ const fromLog = (0, task_identifier_1.matchTaskIdentifier)(log);
55
47
  if (fromLog)
56
48
  return { identifier: fromLog, source: 'commit' };
57
49
  return null;
@@ -0,0 +1,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",