@lumoai/cli 1.10.0 → 1.15.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,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatUnarchiveResult = formatUnarchiveResult;
4
+ exports.milestoneUnarchive = milestoneUnarchive;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const resolve_1 = require("../lib/resolve");
8
+ const sanitize_1 = require("../lib/sanitize");
9
+ function formatUnarchiveResult(name) {
10
+ return `Unarchived "${(0, sanitize_1.sanitizeField)(name)}"`;
11
+ }
12
+ async function milestoneUnarchive(identifier, opts) {
13
+ const creds = (0, config_1.readCredentials)();
14
+ if (!creds) {
15
+ console.error('Error: not logged in. Run `lumo auth login` first.');
16
+ return 1;
17
+ }
18
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
19
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
20
+ let milestoneId;
21
+ try {
22
+ const resolved = await (0, resolve_1.resolveMilestoneId)(base, creds.token, identifier, opts.project);
23
+ milestoneId = resolved.id;
24
+ }
25
+ catch (err) {
26
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
27
+ return 1;
28
+ }
29
+ let res;
30
+ try {
31
+ res = await fetch(`${base}/api/milestones/${milestoneId}/unarchive`, {
32
+ method: 'POST',
33
+ headers: { Authorization: `Bearer ${creds.token}` },
34
+ });
35
+ }
36
+ catch (err) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
39
+ return 1;
40
+ }
41
+ if (res.status === 401) {
42
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
43
+ return 1;
44
+ }
45
+ if (!res.ok) {
46
+ let errMsg = `milestone unarchive failed (HTTP ${res.status})`;
47
+ try {
48
+ const body = (await res.json());
49
+ if (body.error)
50
+ errMsg = body.error;
51
+ }
52
+ catch {
53
+ // non-JSON body; keep the status-only message
54
+ }
55
+ console.error(`Error: ${(0, sanitize_1.sanitizeField)(errMsg)}`);
56
+ return 1;
57
+ }
58
+ const { milestone } = (await res.json());
59
+ process.stdout.write(formatUnarchiveResult(milestone.name) + '\n');
60
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nextCommand = nextCommand;
4
+ exports.formatNextOutput = formatNextOutput;
5
+ const config_1 = require("../lib/config");
6
+ const api_1 = require("../lib/api");
7
+ const sanitize_1 = require("../lib/sanitize");
8
+ const rank_tasks_1 = require("../lib/rank-tasks");
9
+ /**
10
+ * `lumo next [-n, --count <N>]` — recommend the next task(s) to work on.
11
+ *
12
+ * Chains two read-only endpoints: active sprint ids (for a ranking boost) and
13
+ * "my tasks". The active-sprints call is non-fatal — if it fails we warn and
14
+ * rank without the sprint boost rather than aborting.
15
+ */
16
+ async function nextCommand(opts) {
17
+ const count = opts.count !== undefined ? parseInt(opts.count, 10) : 3;
18
+ if (Number.isNaN(count) || count < 1) {
19
+ console.error(`Error: invalid --count "${opts.count}" (expected a positive integer)`);
20
+ return 1;
21
+ }
22
+ const creds = (0, config_1.readCredentials)();
23
+ if (!creds) {
24
+ console.error('Error: not logged in. Run `lumo auth login` first.');
25
+ return 1;
26
+ }
27
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
28
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
29
+ const authHeaders = { Authorization: `Bearer ${creds.token}` };
30
+ // 1. Active sprint ids — non-fatal. Failure just drops the sprint boost.
31
+ let activeSprintIds = new Set();
32
+ try {
33
+ const res = await fetch(`${base}/api/me/active-sprints`, {
34
+ headers: authHeaders,
35
+ });
36
+ if (res.ok) {
37
+ const data = (await res.json());
38
+ activeSprintIds = new Set(data.sprintIds);
39
+ }
40
+ else {
41
+ console.error(`Warning: could not load active sprints (HTTP ${res.status}); ranking without sprint boost.`);
42
+ }
43
+ }
44
+ catch (err) {
45
+ const msg = err instanceof Error ? err.message : String(err);
46
+ console.error(`Warning: could not load active sprints (${msg}); ranking without sprint boost.`);
47
+ }
48
+ // 2. My tasks — fatal on failure.
49
+ let res;
50
+ try {
51
+ res = await fetch(`${base}/api/tasks/me`, { headers: authHeaders });
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ console.error(`Error: could not reach Lumo API at ${apiUrl} (${msg})`);
56
+ return 1;
57
+ }
58
+ if (res.status === 401) {
59
+ console.error('Error: API key invalid or revoked. Run `lumo auth login`.');
60
+ return 1;
61
+ }
62
+ if (!res.ok) {
63
+ console.error(`Error: task fetch failed (HTTP ${res.status})`);
64
+ return 1;
65
+ }
66
+ const data = (await res.json());
67
+ const open = data.tasks.filter(t => t.status !== 'DONE');
68
+ const ranked = (0, rank_tasks_1.rankTasks)(open, activeSprintIds, new Date()).slice(0, count);
69
+ process.stdout.write(formatNextOutput(ranked, open.length) + '\n');
70
+ }
71
+ /**
72
+ * Render the ranked recommendation block. `ranked` is already sliced to the
73
+ * requested count; `totalOpen` is the count of all non-DONE tasks (for the
74
+ * "(of N open)" header). Pure — no I/O.
75
+ */
76
+ function formatNextOutput(ranked, totalOpen) {
77
+ const first = ranked[0];
78
+ if (!first)
79
+ return 'No open tasks assigned to you. 🎉';
80
+ const widths = {
81
+ identifier: Math.max(...ranked.map(r => r.identifier.length)),
82
+ status: Math.max(...ranked.map(r => r.task.status.length)),
83
+ priority: Math.max(...ranked.map(r => r.task.priority.length)),
84
+ };
85
+ const plural = ranked.length === 1 ? '' : 's';
86
+ const lines = [
87
+ `Top ${ranked.length} recommended task${plural} (of ${totalOpen} open):`,
88
+ '',
89
+ ];
90
+ ranked.forEach((r, i) => {
91
+ lines.push(`${i + 1}. ${r.identifier.padEnd(widths.identifier)} ` +
92
+ `${r.task.status.padEnd(widths.status)} ` +
93
+ `${r.task.priority.padEnd(widths.priority)} ` +
94
+ (0, sanitize_1.sanitizeField)(r.task.title));
95
+ lines.push(` ↳ ${r.reasons.join(' · ')}`);
96
+ });
97
+ lines.push('');
98
+ lines.push(`Next: lumo session attach ${first.identifier} && lumo task context ${first.identifier}`);
99
+ if (ranked.length > 1) {
100
+ lines.push('(也可换成列表里任意一个 LUM-N)');
101
+ }
102
+ return lines.join('\n');
103
+ }
@@ -4,13 +4,17 @@ exports.sessionWrap = sessionWrap;
4
4
  const config_1 = require("../lib/config");
5
5
  const wrap_panel_1 = require("../lib/wrap-panel");
6
6
  const progress_comment_section_1 = require("./wrap/progress-comment-section");
7
+ const memory_review_section_1 = require("./wrap/memory-review-section");
8
+ const blocked_prompt_section_1 = require("./wrap/blocked-prompt-section");
7
9
  /**
8
10
  * `lumo session wrap [--yes] [--dry-run]`
9
11
  *
10
- * Session-end wrap-up panel. v1 has a single section: draft a progress comment
11
- * from this session's unposted turnSummaries and post it (after y/e/s
12
- * confirmation) to the bound task. Designed as a multi-section panel so
13
- * LUM-152's memory-review section slots in without touching this command.
12
+ * Session-end wrap-up panel with three sections, run in order: (1) draft a
13
+ * progress comment from this session's unposted turnSummaries and post it
14
+ * (after y/e/s confirmation) to the bound task; (2) review the Layer1 memories
15
+ * this session sedimented keep/delete/promote, deduped by a per-session
16
+ * watermark; (3) if the session repeatedly hit the same failure, prompt whether
17
+ * to flag the bound task with a `blocked` tag (LUM-153, prompt-only).
14
18
  */
15
19
  async function sessionWrap(options) {
16
20
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
@@ -24,7 +28,11 @@ async function sessionWrap(options) {
24
28
  console.error('Error: not logged in. Run `lumo auth login` first.');
25
29
  return 1;
26
30
  }
27
- const sections = [new progress_comment_section_1.ProgressCommentSection({ creds, sessionId })];
31
+ const sections = [
32
+ new progress_comment_section_1.ProgressCommentSection({ creds, sessionId }),
33
+ new memory_review_section_1.MemoryReviewSection({ creds, sessionId }),
34
+ new blocked_prompt_section_1.BlockedPromptSection({ creds, sessionId }),
35
+ ];
28
36
  await (0, wrap_panel_1.runWrapPanel)(sections, {
29
37
  yes: options.yes === true,
30
38
  dryRun: options.dryRun === true,
@@ -60,6 +60,10 @@ function formatTaskContextMarkdown(data, now) {
60
60
  ? `, target ${data.task.milestone.targetDate.slice(0, 10)}`
61
61
  : '';
62
62
  lines.push(`**Milestone**: ${(0, sanitize_1.sanitizeField)(data.task.milestone.name)} (${data.task.milestone.status}${target})`);
63
+ const milestoneGoal = data.task.milestone.description;
64
+ if (milestoneGoal && milestoneGoal.trim().length > 0) {
65
+ lines.push(`**Milestone goal**: ${(0, sanitize_1.sanitizeField)(milestoneGoal)}`);
66
+ }
63
67
  }
64
68
  const body = data.task.descriptionMarkdown ?? data.task.description;
65
69
  if (body && body.trim().length > 0) {
@@ -186,14 +186,22 @@ async function taskUpdate(identifier, opts) {
186
186
  const hasPatchFields = flagsGiven.length > 0 || hasTagFields;
187
187
  if (hasPatchFields) {
188
188
  const patchUrl = `${base}/api/tasks/by-identifier/${encodeURIComponent(identifier)}`;
189
+ // When run inside a Claude Code session, pass its id so a status→DONE
190
+ // update attributes the resulting Layer 2 PROJECT memories to this
191
+ // session — the next session-start surfaces them for review (LUM-165).
192
+ // Absent outside Claude Code; the server treats the header as optional.
193
+ const headers = {
194
+ Authorization: `Bearer ${creds.token}`,
195
+ 'Content-Type': 'application/json',
196
+ };
197
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
198
+ if (sessionId)
199
+ headers['X-Lumo-Session-Id'] = sessionId;
189
200
  let res;
190
201
  try {
191
202
  res = await fetch(patchUrl, {
192
203
  method: 'PATCH',
193
- headers: {
194
- Authorization: `Bearer ${creds.token}`,
195
- 'Content-Type': 'application/json',
196
- },
204
+ headers,
197
205
  body: JSON.stringify(payload),
198
206
  });
199
207
  }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BlockedPromptSection = void 0;
4
+ const sanitize_1 = require("../../lib/sanitize");
5
+ const line_prompt_1 = require("../../lib/line-prompt");
6
+ const failure_summary_api_1 = require("../../lib/failure-summary-api");
7
+ /**
8
+ * Wrap-panel section (LUM-153) that detects repeated same-type failures in this
9
+ * session and *prompts* whether to flag the bound task with a `blocked` tag.
10
+ * Prompt-only by design — it never flips status automatically, and it only
11
+ * shows up when the server says `shouldPrompt` (≥ threshold failures, bound
12
+ * task, not already blocked). Confirming attaches the tag; the empty/`s`
13
+ * default does nothing, so a stray Enter never tags the task.
14
+ */
15
+ class BlockedPromptSection {
16
+ deps;
17
+ title = '卡住检测';
18
+ draft = null;
19
+ constructor(deps) {
20
+ this.deps = deps;
21
+ }
22
+ async prepare() {
23
+ this.draft = await (0, failure_summary_api_1.fetchFailureSummary)(this.deps.creds, this.deps.sessionId);
24
+ return this.draft.shouldPrompt;
25
+ }
26
+ async run(opts) {
27
+ const draft = this.draft;
28
+ if (!draft || !draft.shouldPrompt || !draft.taskIdentifier)
29
+ return;
30
+ const top = draft.topFailure;
31
+ const where = top ? (0, sanitize_1.sanitizeField)(top.label) : '某个操作';
32
+ const count = top ? top.count : 0;
33
+ process.stdout.write(`看起来本次会话反复卡在 ${where}(${count} 次失败)。\n`);
34
+ if (top?.lastErrorSummary) {
35
+ process.stdout.write(`最后错误:${(0, sanitize_1.sanitizeField)(top.lastErrorSummary)}\n`);
36
+ }
37
+ if (opts.dryRun) {
38
+ process.stdout.write(`(dry-run,未改动;确认后会给 ${draft.taskIdentifier} 标 blocked)\n`);
39
+ return;
40
+ }
41
+ // Tagging the shared board is opt-in: it requires an explicit interactive
42
+ // `y`. `--yes` (and non-TTY, where promptLine returns empty) deliberately
43
+ // does NOT auto-tag — silently flipping shared board state is exactly what
44
+ // LUM-153 set out to avoid. We surface the suggestion and move on.
45
+ if (opts.yes) {
46
+ process.stdout.write(`(--yes 不自动标记;如确认请交互式回答 y,或手动 \`lumo task update ${draft.taskIdentifier} --add-tag blocked\`)\n`);
47
+ return;
48
+ }
49
+ const choice = (await (0, line_prompt_1.promptLine)(`要在 ${draft.taskIdentifier} 标 blocked 吗?[y] 标记 [s] 跳过 > `))
50
+ .trim()
51
+ .toLowerCase();
52
+ if (choice === 'y') {
53
+ await this.mark();
54
+ return;
55
+ }
56
+ // Empty / 's' / anything else → do nothing. Tagging is opt-in.
57
+ process.stdout.write('已跳过,未标记。\n');
58
+ }
59
+ async mark() {
60
+ const { taskIdentifier, tag } = await (0, failure_summary_api_1.markTaskBlocked)(this.deps.creds, this.deps.sessionId);
61
+ process.stdout.write(`已给 ${taskIdentifier} 标 ${tag}。\n`);
62
+ }
63
+ }
64
+ exports.BlockedPromptSection = BlockedPromptSection;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryReviewSection = void 0;
4
+ exports.parseReviewInstruction = parseReviewInstruction;
5
+ const sanitize_1 = require("../../lib/sanitize");
6
+ const line_prompt_1 = require("../../lib/line-prompt");
7
+ const memory_content_1 = require("../../lib/memory-content");
8
+ const session_memory_api_1 = require("../../lib/session-memory-api");
9
+ /** Parse a one-line review instruction into 0-based row indices. */
10
+ function parseReviewInstruction(line) {
11
+ const result = { deleteIdx: [], promoteIdx: [] };
12
+ const re = /([dp])\s*([\d,\s]+)/gi;
13
+ let m;
14
+ while ((m = re.exec(line)) !== null) {
15
+ const nums = m[2]
16
+ .split(/[\s,]+/)
17
+ .map(s => s.trim())
18
+ .filter(Boolean)
19
+ .map(s => parseInt(s, 10) - 1)
20
+ .filter(n => Number.isInteger(n) && n >= 0);
21
+ if (m[1].toLowerCase() === 'd')
22
+ result.deleteIdx.push(...nums);
23
+ else
24
+ result.promoteIdx.push(...nums);
25
+ }
26
+ return result;
27
+ }
28
+ /**
29
+ * Wrap-panel section that lists the Layer1 memories this session sedimented and
30
+ * lets the user delete noise / promote keepers to project scope. Keeps its own
31
+ * draft state between prepare() and run(). Dedup is server-side via watermark.
32
+ */
33
+ class MemoryReviewSection {
34
+ deps;
35
+ title = '记忆审阅';
36
+ draft = null;
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ }
40
+ async prepare() {
41
+ this.draft = await (0, session_memory_api_1.fetchMemoryDraft)(this.deps.creds, this.deps.sessionId);
42
+ return this.draft.memories.length > 0;
43
+ }
44
+ async run(opts) {
45
+ const draft = this.draft;
46
+ if (!draft || !draft.watermark || draft.memories.length === 0)
47
+ return;
48
+ process.stdout.write(`本次会话新增了这 ${draft.memories.length} 条 memory:\n`);
49
+ process.stdout.write(`${(0, sanitize_1.sanitizeField)((0, memory_content_1.formatMemoryReviewList)(draft.memories))}\n`);
50
+ if (opts.dryRun) {
51
+ process.stdout.write('(dry-run,未改动)\n');
52
+ return;
53
+ }
54
+ if (opts.yes) {
55
+ await this.apply(draft.watermark, [], []);
56
+ return;
57
+ }
58
+ const line = (await (0, line_prompt_1.promptLine)('[回车] 全部保留 [d 1,3] 删除 [p 2] 提升到项目级 [s] 跳过 > ')).trim();
59
+ if (line.toLowerCase() === 's') {
60
+ process.stdout.write('已跳过本节。\n');
61
+ return;
62
+ }
63
+ if (line === '') {
64
+ await this.apply(draft.watermark, [], []);
65
+ return;
66
+ }
67
+ const { deleteIdx, promoteIdx } = parseReviewInstruction(line);
68
+ const inRange = (n) => n >= 0 && n < draft.memories.length;
69
+ const deleteIds = deleteIdx.filter(inRange).map(i => draft.memories[i].id);
70
+ const promoteIds = promoteIdx
71
+ .filter(inRange)
72
+ .map(i => draft.memories[i].id)
73
+ .filter(id => !deleteIds.includes(id));
74
+ await this.apply(draft.watermark, deleteIds, promoteIds);
75
+ }
76
+ async apply(watermark, deleteIds, promoteIds) {
77
+ const { deleted, promoted } = await (0, session_memory_api_1.applyMemoryReview)(this.deps.creds, this.deps.sessionId, { watermark, deleteIds, promoteIds });
78
+ process.stdout.write(`已删除 ${deleted} 条,提升 ${promoted} 条到项目级。\n`);
79
+ }
80
+ }
81
+ exports.MemoryReviewSection = MemoryReviewSection;
@@ -46,6 +46,7 @@ const session_attach_1 = require("./commands/session-attach");
46
46
  const session_detach_1 = require("./commands/session-detach");
47
47
  const session_status_1 = require("./commands/session-status");
48
48
  const session_wrap_1 = require("./commands/session-wrap");
49
+ const next_1 = require("./commands/next");
49
50
  const task_context_1 = require("./commands/task-context");
50
51
  const task_create_1 = require("./commands/task-create");
51
52
  const task_update_1 = require("./commands/task-update");
@@ -78,9 +79,13 @@ const milestone_create_1 = require("./commands/milestone-create");
78
79
  const milestone_show_1 = require("./commands/milestone-show");
79
80
  const milestone_update_1 = require("./commands/milestone-update");
80
81
  const milestone_delete_1 = require("./commands/milestone-delete");
82
+ const milestone_archive_1 = require("./commands/milestone-archive");
83
+ const milestone_unarchive_1 = require("./commands/milestone-unarchive");
81
84
  const milestone_add_1 = require("./commands/milestone-add");
82
85
  const milestone_remove_1 = require("./commands/milestone-remove");
83
86
  const milestone_summary_1 = require("./commands/milestone-summary");
87
+ const milestone_reorder_1 = require("./commands/milestone-reorder");
88
+ const milestone_move_1 = require("./commands/milestone-move");
84
89
  const sprint_create_1 = require("./commands/sprint-create");
85
90
  const sprint_list_1 = require("./commands/sprint-list");
86
91
  const sprint_show_1 = require("./commands/sprint-show");
@@ -184,6 +189,11 @@ program
184
189
  .option('--force', 'Overwrite an existing SKILL.md when its contents differ from the bundled version')
185
190
  .option('--agent <token>', 'Coding agent these hooks run under (claude-code, codex, cursor, gemini-cli, github-copilot, windsurf). Baked into every hook command. Defaults to claude-code.')
186
191
  .action(wrap(options => (0, setup_1.setup)(options)));
192
+ program
193
+ .command('next')
194
+ .description('Recommend the next task(s) to work on, ranked by priority, active sprint, and due date. Prints top N (default 3); pick one and run `session attach` + `task context`.')
195
+ .option('-n, --count <N>', 'Number of tasks to recommend (default 3)')
196
+ .action(wrap(options => (0, next_1.nextCommand)(options)));
187
197
  const session = program
188
198
  .command('session')
189
199
  .description('Manage per-terminal coding-session context');
@@ -405,8 +415,11 @@ const milestoneCmd = program
405
415
  .description('Inspect milestones from the terminal');
406
416
  milestoneCmd
407
417
  .command('list')
408
- .description('List milestones for a project. --project required when workspace has >1 project.')
418
+ .description('List milestones for a project. --project required when workspace has >1 project. By default only non-archived milestones are shown.')
409
419
  .option('--project <ref>', 'Project name or slug')
420
+ .option('--archived', 'Show only archived milestones')
421
+ .option('--all', 'Show both archived and non-archived milestones')
422
+ .option('--search <text>', 'Filter by name/description substring (case-insensitive)')
410
423
  .action(wrap(options => (0, milestone_list_1.milestoneList)(options)));
411
424
  milestoneCmd
412
425
  .command('create <name>')
@@ -437,6 +450,16 @@ milestoneCmd
437
450
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
438
451
  .option('--yes', 'Required: confirm deletion without TTY prompt')
439
452
  .action(wrap((identifier, options) => (0, milestone_delete_1.milestoneDelete)(identifier, options)));
453
+ milestoneCmd
454
+ .command('archive <identifier>')
455
+ .description('Archive a milestone: hidden from `milestone list` by default, history and task links preserved, reversible with `milestone unarchive`. Identifier accepts UUID or name.')
456
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
457
+ .action(wrap((identifier, options) => (0, milestone_archive_1.milestoneArchive)(identifier, options)));
458
+ milestoneCmd
459
+ .command('unarchive <identifier>')
460
+ .description('Restore an archived milestone so it shows in `milestone list` again. Identifier accepts UUID or name.')
461
+ .option('--project <ref>', 'Project name or slug (when identifier is a name)')
462
+ .action(wrap((identifier, options) => (0, milestone_unarchive_1.milestoneUnarchive)(identifier, options)));
440
463
  milestoneCmd
441
464
  .command('add <identifier> <tasks...>')
442
465
  .description('Bind one or more tasks to a milestone in one call. <identifier> accepts a name or UUID; each <task> accepts LUM-N or UUID. Per-task result with a tally; partial failures do not roll back.')
@@ -453,6 +476,18 @@ milestoneCmd
453
476
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
454
477
  .option('--retry', 'Trigger summary regeneration before fetching')
455
478
  .action(wrap((identifier, options) => (0, milestone_summary_1.milestoneSummary)(identifier, options)));
479
+ milestoneCmd
480
+ .command('reorder <refs...>')
481
+ .description("Reorder a project's milestones. Pass every milestone (name or UUID) in the desired order. --project required when workspace has >1 project.")
482
+ .option('--project <ref>', 'Project name or slug')
483
+ .action(wrap((refs, options) => (0, milestone_reorder_1.milestoneReorder)(refs, options)));
484
+ milestoneCmd
485
+ .command('move <ref>')
486
+ .description('Move one milestone before or after another. --before and --after are mutually exclusive; exactly one is required. --project required when workspace has >1 project.')
487
+ .option('--project <ref>', 'Project name or slug')
488
+ .option('--before <ref>', 'Place <ref> immediately before this milestone')
489
+ .option('--after <ref>', 'Place <ref> immediately after this milestone')
490
+ .action(wrap((ref, options) => (0, milestone_move_1.milestoneMove)(ref, options)));
456
491
  const sprintCmd = program
457
492
  .command('sprint')
458
493
  .description('Inspect sprints from the terminal');
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchFailureSummary = fetchFailureSummary;
4
+ exports.markTaskBlocked = markTaskBlocked;
5
+ const api_1 = require("./api");
6
+ function base(creds) {
7
+ return (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
8
+ }
9
+ /** GET the blocked-tag prompt draft for the session. Throws on transport / non-200. */
10
+ async function fetchFailureSummary(creds, sessionId) {
11
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/failure-summary`;
12
+ const res = await fetch(url, {
13
+ headers: { Authorization: `Bearer ${creds.token}` },
14
+ });
15
+ if (res.status === 401)
16
+ throw new Error('API key invalid or revoked. Run `lumo auth login`.');
17
+ if (!res.ok)
18
+ throw new Error(`failure summary fetch failed (HTTP ${res.status})`);
19
+ return (await res.json());
20
+ }
21
+ /** POST to attach the `blocked` tag to the session's bound task. Throws the server message on non-201. */
22
+ async function markTaskBlocked(creds, sessionId) {
23
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/mark-blocked`;
24
+ const res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: { Authorization: `Bearer ${creds.token}` },
27
+ });
28
+ if (res.status === 401)
29
+ throw new Error('API key invalid or revoked. Run `lumo auth login`.');
30
+ if (res.status !== 201) {
31
+ let serverMsg = null;
32
+ try {
33
+ const errBody = (await res.json());
34
+ if (typeof errBody.error === 'string')
35
+ serverMsg = errBody.error;
36
+ }
37
+ catch {
38
+ // body wasn't JSON
39
+ }
40
+ throw new Error(serverMsg ?? `mark blocked failed (HTTP ${res.status})`);
41
+ }
42
+ return (await res.json());
43
+ }
@@ -115,6 +115,7 @@ function formatHookStdoutLines(path, responseBody, now = new Date()) {
115
115
  body.memorySection,
116
116
  body.linkedResourcesSection,
117
117
  body.reviewTodosSection,
118
+ body.layer2ReviewSection,
118
119
  ]);
119
120
  if (envelope)
120
121
  lines.push(envelope);
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildMemoryContent = buildMemoryContent;
4
4
  exports.formatMemoryList = formatMemoryList;
5
+ exports.formatMemoryReviewList = formatMemoryReviewList;
5
6
  // Category/field metadata + builders for the `lumo memory` commands.
6
7
  // Mirrors the four content shapes validated server-side by parseMemoryContent.
7
8
  const sanitize_1 = require("./sanitize");
@@ -86,3 +87,9 @@ function formatMemoryList(rows) {
86
87
  })
87
88
  .join('\n');
88
89
  }
90
+ /** Numbered review list: ` N. [SCOPE] CATEGORY headline`. 1-indexed. */
91
+ function formatMemoryReviewList(rows) {
92
+ return rows
93
+ .map((r, i) => ` ${i + 1}. [${r.scope}] ${r.category} ${headline(r.category, r.content)}`)
94
+ .join('\n');
95
+ }
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveOrderedIds = resolveOrderedIds;
4
+ exports.computeMoveOrder = computeMoveOrder;
5
+ function findRef(ref, milestones) {
6
+ // Try by id first (exact match — milestone ids are cuids, unique).
7
+ const byId = milestones.find(m => m.id === ref);
8
+ if (byId)
9
+ return { kind: 'found', ref: byId };
10
+ // Then by name (case-insensitive). Names are NOT unique within a project
11
+ // (only slug is), so a name can match more than one milestone.
12
+ const needle = ref.trim().toLowerCase();
13
+ const hits = milestones.filter(m => m.name.toLowerCase() === needle);
14
+ if (hits.length === 0)
15
+ return { kind: 'not-found' };
16
+ if (hits.length === 1)
17
+ return { kind: 'found', ref: hits[0] };
18
+ return { kind: 'ambiguous', candidates: hits };
19
+ }
20
+ function ambiguousError(ref, candidates) {
21
+ const ids = candidates.map(c => c.id).join(', ');
22
+ return `ambiguous milestone name "${ref}" matches ${candidates.length} milestones; re-run with the id: ${ids}`;
23
+ }
24
+ /** Milestones in current display order (sortOrder asc, stable). */
25
+ function sorted(milestones) {
26
+ return [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
27
+ }
28
+ /**
29
+ * Resolve a full list of milestone refs (name or UUID) to ids in the given
30
+ * order. The list must name EVERY milestone in the project exactly once.
31
+ */
32
+ function resolveOrderedIds(refs, milestones) {
33
+ const ids = [];
34
+ const seen = new Set();
35
+ for (const ref of refs) {
36
+ const found = findRef(ref, milestones);
37
+ if (found.kind === 'not-found') {
38
+ return { ok: false, error: `unknown milestone: "${ref}"` };
39
+ }
40
+ if (found.kind === 'ambiguous') {
41
+ return { ok: false, error: ambiguousError(ref, found.candidates) };
42
+ }
43
+ const match = found.ref;
44
+ if (seen.has(match.id)) {
45
+ return { ok: false, error: `duplicate milestone: "${ref}"` };
46
+ }
47
+ seen.add(match.id);
48
+ ids.push(match.id);
49
+ }
50
+ if (ids.length !== milestones.length) {
51
+ const missing = milestones
52
+ .filter(m => !seen.has(m.id))
53
+ .map(m => m.name);
54
+ return {
55
+ ok: false,
56
+ error: `incomplete order: list every milestone. Missing: ${missing.join(', ')}`,
57
+ };
58
+ }
59
+ return { ok: true, orderedIds: ids };
60
+ }
61
+ /**
62
+ * Compute the full orderedIds after moving `moveRef` immediately before/after
63
+ * `targetRef` in the project's current order.
64
+ */
65
+ function computeMoveOrder(moveRef, targetRef, position, milestones) {
66
+ const moveFound = findRef(moveRef, milestones);
67
+ if (moveFound.kind === 'not-found') {
68
+ return { ok: false, error: `unknown milestone: "${moveRef}"` };
69
+ }
70
+ if (moveFound.kind === 'ambiguous') {
71
+ return { ok: false, error: ambiguousError(moveRef, moveFound.candidates) };
72
+ }
73
+ const targetFound = findRef(targetRef, milestones);
74
+ if (targetFound.kind === 'not-found') {
75
+ return { ok: false, error: `unknown milestone: "${targetRef}"` };
76
+ }
77
+ if (targetFound.kind === 'ambiguous') {
78
+ return { ok: false, error: ambiguousError(targetRef, targetFound.candidates) };
79
+ }
80
+ const move = moveFound.ref;
81
+ const target = targetFound.ref;
82
+ if (move.id === target.id) {
83
+ return { ok: false, error: 'cannot move a milestone relative to itself' };
84
+ }
85
+ const order = sorted(milestones)
86
+ .map(m => m.id)
87
+ .filter(id => id !== move.id);
88
+ const targetIndex = order.indexOf(target.id);
89
+ const insertAt = position === 'before' ? targetIndex : targetIndex + 1;
90
+ order.splice(insertAt, 0, move.id);
91
+ return { ok: true, orderedIds: order };
92
+ }