@lumoai/cli 1.58.0 → 1.60.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.
Files changed (47) hide show
  1. package/assets/skill/SKILL.md +15 -17
  2. package/assets/skill/references/artifacts-figma.md +4 -3
  3. package/assets/skill/references/confirmation.md +133 -0
  4. package/assets/skill/references/criteria.md +12 -21
  5. package/assets/skill/references/doc-editing.md +11 -9
  6. package/assets/skill/references/docs.md +4 -3
  7. package/assets/skill/references/memory.md +4 -2
  8. package/assets/skill/references/milestones.md +3 -2
  9. package/assets/skill/references/outcome.md +1 -14
  10. package/assets/skill/references/plan-runs.md +5 -0
  11. package/assets/skill/references/sessions.md +4 -4
  12. package/assets/skill/references/sprints.md +18 -17
  13. package/assets/skill/references/task-deps.md +4 -3
  14. package/assets/skill/references/tasks.md +34 -2
  15. package/assets/skill/references/verify.md +71 -71
  16. package/assets/skill/references/worktree.md +13 -7
  17. package/dist/cli/src/commands/crossing-disposition.js +342 -0
  18. package/dist/cli/src/commands/crossing-explain.js +10 -21
  19. package/dist/cli/src/commands/doc-delete.js +35 -23
  20. package/dist/cli/src/commands/doc-rebuild-source.js +16 -4
  21. package/dist/cli/src/commands/memory-rm.js +68 -10
  22. package/dist/cli/src/commands/milestone-delete.js +13 -10
  23. package/dist/cli/src/commands/outcome.js +0 -77
  24. package/dist/cli/src/commands/session-attach.js +8 -2
  25. package/dist/cli/src/commands/sprint-close.js +29 -9
  26. package/dist/cli/src/commands/sprint-delete.js +13 -10
  27. package/dist/cli/src/commands/sprint-show.js +3 -9
  28. package/dist/cli/src/commands/task-artifact-rm.js +58 -28
  29. package/dist/cli/src/commands/task-criteria-list.js +1 -4
  30. package/dist/cli/src/commands/task-criteria-set.js +3 -12
  31. package/dist/cli/src/commands/task-deps.js +20 -6
  32. package/dist/cli/src/commands/task-status.js +196 -111
  33. package/dist/cli/src/commands/task-update.js +129 -0
  34. package/dist/cli/src/commands/verify.js +22 -13
  35. package/dist/cli/src/commands/worktree-rm.js +35 -7
  36. package/dist/cli/src/index.js +60 -48
  37. package/dist/cli/src/lib/blocked-error.js +183 -0
  38. package/dist/cli/src/lib/bound-task.js +32 -0
  39. package/dist/cli/src/lib/confirmation.js +119 -0
  40. package/dist/cli/src/lib/hook-runner.js +23 -11
  41. package/dist/cli/src/lib/open-crossings.js +6 -6
  42. package/dist/shared/src/referent-kind.js +31 -1
  43. package/dist/shared/src/security-scan.js +125 -0
  44. package/package.json +1 -1
  45. package/assets/skill/references/fidelity.md +0 -32
  46. package/dist/cli/src/commands/fidelity.js +0 -108
  47. package/dist/cli/src/commands/verdict.js +0 -189
@@ -1,8 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeWorktreeRm = describeWorktreeRm;
3
4
  exports.worktreeRm = worktreeRm;
4
5
  const worktree_1 = require("../lib/worktree");
5
6
  const worktree_ref_1 = require("../lib/worktree-ref");
7
+ const confirmation_1 = require("../lib/confirmation");
8
+ /**
9
+ * The changes[] block for the confirmation envelope. `dirty` adds the
10
+ * discard warning that --force acknowledges; `deleteBranch` flips the branch
11
+ * line from "kept" to "deleted".
12
+ */
13
+ function describeWorktreeRm(args) {
14
+ const lines = [
15
+ `Will remove worktree ${args.path}${args.branch ? ` (branch ${args.branch})` : ''}`,
16
+ ];
17
+ if (args.dirty) {
18
+ lines.push('⚠ The worktree has uncommitted changes — they will be discarded (--force acknowledges this)');
19
+ }
20
+ if (args.branch) {
21
+ lines.push(args.deleteBranch
22
+ ? `Branch ${args.branch} will be deleted too (--delete-branch; git refuses an unmerged branch)`
23
+ : `Branch ${args.branch} is kept (pass --delete-branch to delete it)`);
24
+ }
25
+ return lines;
26
+ }
6
27
  async function worktreeRm(rawId, opts) {
7
28
  let taskId;
8
29
  try {
@@ -27,14 +48,21 @@ async function worktreeRm(rawId, opts) {
27
48
  console.error(`Error: no worktree found for ${taskId}`);
28
49
  return 1;
29
50
  }
30
- if (!opts.yes) {
31
- console.error(`Error: refusing to remove ${target.path} without --yes (destructive)`);
32
- return 1;
33
- }
34
51
  const dirty = (0, worktree_1.gitOutput)(['status', '--porcelain'], target.path).length > 0;
35
- if (dirty && !opts.force) {
36
- console.error(`Error: ${target.path} has uncommitted changes pass --force to remove anyway`);
37
- return 1;
52
+ // LUM-755: two gates, one envelope. Missing --confirm, or a dirty tree
53
+ // without --force, both stop here with the same structured refusal; the
54
+ // confirmCommand carries whichever flags are still missing.
55
+ if (!(0, confirmation_1.isConfirmed)(opts) || (dirty && !opts.force)) {
56
+ return (0, confirmation_1.emitConfirmation)({
57
+ command: 'worktree rm',
58
+ changes: describeWorktreeRm({
59
+ path: target.path,
60
+ branch: target.branch ?? null,
61
+ dirty,
62
+ deleteBranch: Boolean(opts.deleteBranch),
63
+ }),
64
+ extraFlags: dirty ? ['--force'] : [],
65
+ });
38
66
  }
39
67
  const removeArgs = dirty && opts.force
40
68
  ? ['worktree', 'remove', '--force', target.path]
@@ -64,10 +64,9 @@ const plan_1 = require("./commands/plan");
64
64
  const cost_1 = require("./commands/cost");
65
65
  const priority_1 = require("./commands/priority");
66
66
  const criteria_audit_1 = require("./commands/criteria-audit");
67
- const fidelity_1 = require("./commands/fidelity");
68
67
  const verify_1 = require("./commands/verify");
69
- const verdict_1 = require("./commands/verdict");
70
68
  const crossing_explain_1 = require("./commands/crossing-explain");
69
+ const crossing_disposition_1 = require("./commands/crossing-disposition");
71
70
  const outcome_1 = require("./commands/outcome");
72
71
  const task_context_1 = require("./commands/task-context");
73
72
  const task_create_1 = require("./commands/task-create");
@@ -194,6 +193,21 @@ if (!isIntrospect) {
194
193
  catch { }
195
194
  }
196
195
  const collect = (val, acc) => [...acc, val];
196
+ /**
197
+ * Exit-code convention (LUM-755). A command returns the code to propagate:
198
+ * 0 / undefined success
199
+ * 1 general error (message on stderr)
200
+ * 4 confirmation required — a mutation that needs the user's
201
+ * approval printed a `confirmation_required` envelope on
202
+ * stdout (see cli/src/lib/confirmation.ts); re-run the
203
+ * envelope's `confirmCommand` once the user has approved
204
+ * 5 blocked by a human-only gate — the server refused the
205
+ * change (today: a DONE gate) and the CLI printed a
206
+ * `DONE_BLOCKED` error on stdout listing every blocker
207
+ * (see cli/src/lib/blocked-error.ts); nothing on the CLI
208
+ * can clear it — relay it, don't retry
209
+ * 2 (auth) and 3 (validation) are reserved for a later pass.
210
+ */
197
211
  function wrap(fn) {
198
212
  return async (...args) => {
199
213
  try {
@@ -257,39 +271,34 @@ program
257
271
  .command('verify [task]')
258
272
  .description('Machine verification loop (LUM-343): run every MACHINE criterion checkpointer locally, report structured verdicts to the server (round cap 3), and print next actions. All-pass moves the task to IN_REVIEW. Defaults to the session-bound task.')
259
273
  .option('--timeout <seconds>', 'Per-checkpointer timeout in seconds (default 600)')
260
- .option('--note <text>', 'Required self-report — what you did and why it is ready (LUM-597). Recorded as your claim (source AGENT) when the round passes into IN_REVIEW, and checked against the diff for faithfulness.')
274
+ .option('--note <text>', 'Optional one-line self-report — what you did and why it is ready. When given, it is recorded as your claim (source AGENT) when the round passes into IN_REVIEW.')
261
275
  .action(wrap((task, options) => (0, verify_1.verify)(task, options)));
262
- program
263
- .command('verdict [task]')
264
- .description('Acceptance verdict (LUM-422). --pass opens the browser to the human verdict bar focused on Pass (a deep link — records nothing; a passing row is only ever a human click). --fail --reason <enum> records an AGENT send-back and bounces the task to IN_PROGRESS. Defaults to the session-bound task.')
265
- .option('--pass', 'Open the verdict bar focused on Pass (human one-click; no write)')
266
- .option('--fail', 'Record an AGENT send-back (verdict FAIL) — requires --reason')
267
- .option('--reason <enum>', 'Rejection reason for --fail: CRITERION_UNMET | EVIDENCE_INSUFFICIENT | CHECK_EXECUTION_ERROR | SCOPE_MISMATCH | OTHER (case-insensitive)')
268
- .option('--note <text>', 'Optional send-back narrative, posted as a task comment')
269
- .option('--criterion <id>', 'Narrow a --fail to specific criteria (repeatable); omitted = the whole contract', verdict_1.collectCriterion)
270
- .action(wrap((task, options) => (0, verdict_1.verdict)(task, options)));
271
276
  const crossing = program
272
277
  .command('crossing')
273
- .description('Inspect and annotate boundary crossings');
278
+ .description('Inspect, annotate and (with user approval) disposition boundary crossings');
274
279
  crossing
275
280
  .command('explain <id>')
276
281
  .description('Append an agent self-explanation ("申辩") to a boundary crossing (LUM-542). Append-only and for the human reviewer — it never clears the crossing or unblocks Done (a human dispositions that). Targets a crossing on the session-bound task.')
277
282
  .requiredOption('--note <text>', 'The explanation to record (the rationale for the action / why it may be a false positive)')
278
283
  .action(wrap((id, options) => (0, crossing_explain_1.crossingExplain)(id, options)));
284
+ crossing
285
+ .command('disposition <id>')
286
+ .description("Rule on a boundary crossing from the terminal (LUM-769) through a three-step confirmation handshake the server enforces: (1) no step flag — prints the crossing (severity, detail, agent explanations) as an envelope with a stage-1 read receipt, exit 4, writes nothing; (2) --confirm-read --receipt <r1> after the user confirms they READ it — returns the stage-2 receipt and a second envelope for the ruling, exit 4; (3) --confirm --receipt <r2> after the user approves the ruling — writes. Confirming before the read is acknowledged is refused and told to confirm the read first. Relay each envelope; never supply a step flag yourself. Either ruling clears the crossing's block on DONE and is audited as a CLI-channel disposition. Reverting to OPEN and repository suppression rules stay web-only. Targets a crossing on the session-bound task unless --task is given.")
287
+ .option('--false-positive', 'Rule the crossing a false positive (not a real issue)')
288
+ .option('--confirmed', 'Rule the crossing confirmed (a real issue, acknowledged)')
289
+ .option('--note <text>', 'Optional disposition note recorded with the ruling')
290
+ .option('--task <identifier>', 'Task the crossing belongs to (defaults to the session-bound task)')
291
+ .option('--receipt <token>', "Read receipt from the previous step's envelope (its confirmCommand carries it); required with --confirm-read (stage 1 receipt) and --confirm (stage 2 receipt), verified server-side")
292
+ .option('--confirm-read', 'Step 2: the user confirmed they have read the envelope (never add this on your own initiative). Returns the ruling envelope; writes nothing')
293
+ .option('--confirm', 'Step 3: the user approved the ruling (never add this on your own initiative). Refused unless the read was confirmed first')
294
+ .action(wrap((id, options) => (0, crossing_disposition_1.crossingDisposition)(id, options)));
279
295
  const outcome = program
280
296
  .command('outcome')
281
- .description('Read / record the post-hoc outcome well (LUM-598)');
297
+ .description('Read the post-hoc outcome well (LUM-598)');
282
298
  outcome
283
299
  .command('show <task>')
284
300
  .description('Read the post-hoc outcome well for a task: the falsifier verdict (REJECTED | INCONCLUSIVE — never a "pass") plus its backing rejection signals. INCONCLUSIVE means no rejection on record, NOT satisfied.')
285
301
  .action(wrap((task) => (0, outcome_1.outcomeShow)(task)));
286
- outcome
287
- .command('record <task>')
288
- .description('Record a human-observed post-hoc REJECTION of a delivery (revert / rollback / CI regression / downstream redirect / bypass). Append-only; there is no "mark satisfied" counterpart — the well only asserts rejection.')
289
- .requiredOption('--note <text>', 'What reality did (the observed referent), e.g. "reverted in #812 after a prod incident"')
290
- .option('--kind <kind>', 'reverted | rolled_back | ci_regression | downstream_redirect | bypassed | manual (case-insensitive; default: manual)')
291
- .option('--occurred-at <iso>', 'When the event happened (ISO 8601; defaults to now)')
292
- .action(wrap((task, options) => (0, outcome_1.outcomeRecord)(task, options)));
293
302
  outcome
294
303
  .command('rate')
295
304
  .description('Trust × post-hoc fate: per delivery-time forecast-confidence bracket, the post-hoc REJECTED rate from the outcome well. Honest by construction — thin brackets read "insufficient" and the high-vs-low comparison stays "inconclusive" until the well has enough signal. The rate is a LOWER BOUND (no signal = INCONCLUSIVE, never satisfied).')
@@ -432,14 +441,6 @@ criteria
432
441
  .command('audit')
433
442
  .description('Show the workspace referent-kind distribution + self-confirming-green ratio (GET /api/criteria/audit)')
434
443
  .action(wrap(() => (0, criteria_audit_1.criteriaAudit)()));
435
- const fidelity = program
436
- .command('fidelity')
437
- .description('Mechanical fidelity read-outs (LUM-610)');
438
- fidelity
439
- .command('show <task>')
440
- .description('Per delivery snapshot (LUM-609): the grounding composition of the frozen contract (grounded / self-confirming / inconclusive) and the independence signal (criteria ADDED/UPDATED after work started = backward-inference suspects). A composition table, not a single score — direction is never judged. inconclusive is surfaced, never swallowed.')
441
- .option('--json', 'Emit the report as JSON')
442
- .action(wrap((task, options) => (0, fidelity_1.fidelityShow)(task, options)));
443
444
  const session = program
444
445
  .command('session')
445
446
  .description('Manage per-terminal coding-session context');
@@ -473,8 +474,9 @@ task
473
474
  .action(wrap(identifier => (0, task_show_1.taskShow)(identifier)));
474
475
  task
475
476
  .command('status [task]')
476
- .description('Acceptance self-check (LUM-344): contract with latest verdicts, verification history, current round, and nextActions (unmet criteria). Read-only, no LLM. Defaults to the session-bound task. Run it first when resuming a task or after a verification round was rejected.')
477
+ .description('Acceptance self-check (LUM-344): contract with latest verdicts, current round, nextActions (unmet criteria + undispositioned security findings, LUM-737), and open boundary crossings. Read-only, no LLM. Defaults to the session-bound task. Run it first when resuming a task or after a verification round was rejected. --full adds the dashboard sections (verification rollup, history, cost, struggle/rework trail, trend).')
477
478
  .option('--json', 'Emit the versioned machine-readable payload (version field; breaking changes bump it)')
479
+ .option('--full', 'Also print the dashboard sections: verification rollup, history, cost, struggle/rework trail, trend')
478
480
  .action(wrap((taskArg, options) => (0, task_status_1.taskStatus)(taskArg, options)));
479
481
  task
480
482
  .command('comment <identifier> <body>')
@@ -567,8 +569,9 @@ taskDeps
567
569
  .action(wrap((id, edge) => (0, task_deps_1.taskDepsDismiss)(id, edge)));
568
570
  taskDeps
569
571
  .command('rm <identifier> <edge>')
570
- .option('--yes', 'skip confirmation')
571
- .description('Delete a dependency edge')
572
+ .description('Delete a dependency edge. Without --confirm: exit 4 + a confirmation envelope describing the edge.')
573
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
574
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
572
575
  .action(wrap((id, edge, opts) => (0, task_deps_1.taskDepsRm)(id, edge, opts)));
573
576
  const taskPr = task.command('pr').description('Inspect linked PRs');
574
577
  taskPr
@@ -652,8 +655,9 @@ taskArtifact
652
655
  .action(wrap((taskId, artifactId) => (0, task_artifact_show_1.taskArtifactShow)(taskId, artifactId)));
653
656
  taskArtifact
654
657
  .command('rm <task> <artifact-id>')
655
- .description('Delete an artifact from a task. Irreversible requires --yes.')
656
- .option('--yes', 'Confirm deletion (required, no interactive prompt)')
658
+ .description('Delete an artifact from a task (irreversible). Without --confirm: exit 4 + a confirmation envelope naming the artifact.')
659
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
660
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
657
661
  .action(wrap((taskId, artifactId, options) => (0, task_artifact_rm_1.taskArtifactRm)(taskId, artifactId, options)));
658
662
  const projectCmd = program
659
663
  .command('project')
@@ -701,8 +705,9 @@ memoryCmd
701
705
  .action(wrap((id) => (0, memory_promote_1.memoryPromote)(id)));
702
706
  memoryCmd
703
707
  .command('rm <memoryId>')
704
- .description('Delete a memory (hard delete). Requires --yes.')
705
- .option('--yes', 'Confirm deletion')
708
+ .description('Delete a memory (hard delete). Without --confirm: exit 4 + a confirmation envelope showing the card.')
709
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
710
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
706
711
  .action(wrap((id, opts) => (0, memory_rm_1.memoryRm)(id, opts)));
707
712
  memoryCmd
708
713
  .command('sync')
@@ -762,9 +767,10 @@ milestoneCmd
762
767
  .action(wrap((identifier, options) => (0, milestone_update_1.milestoneUpdate)(identifier, options)));
763
768
  milestoneCmd
764
769
  .command('delete <identifier>')
765
- .description('Delete a milestone. Tasks under it keep their data (milestoneId is cleared). Requires --yes.')
770
+ .description('Delete a milestone. Tasks under it keep their data (milestoneId is cleared). Without --confirm: exit 4 + a confirmation envelope with the task count.')
766
771
  .option('--project <ref>', 'Project name or slug (when identifier is a name)')
767
- .option('--yes', 'Required: confirm deletion without TTY prompt')
772
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
773
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
768
774
  .action(wrap((identifier, options) => (0, milestone_delete_1.milestoneDelete)(identifier, options)));
769
775
  milestoneCmd
770
776
  .command('archive <identifier>')
@@ -874,9 +880,10 @@ sprintCmd
874
880
  .action(wrap((identifier, options) => (0, sprint_update_1.sprintUpdate)(identifier, options)));
875
881
  sprintCmd
876
882
  .command('delete <identifier>')
877
- .description('Delete a DRAFT sprint. Requires --yes. Server rejects ACTIVE/CLOSED sprints.')
883
+ .description('Delete a DRAFT sprint. Without --confirm: exit 4 + a confirmation envelope with the task count. Server rejects ACTIVE/CLOSED sprints.')
878
884
  .option('--team <ref>', 'Team name or UUID (informational)')
879
- .option('--yes', 'Confirm deletion')
885
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
886
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
880
887
  .action(wrap((identifier, options) => (0, sprint_delete_1.sprintDelete)(identifier, options)));
881
888
  sprintCmd
882
889
  .command('start <identifier>')
@@ -885,11 +892,12 @@ sprintCmd
885
892
  .action(wrap((identifier, options) => (0, sprint_start_1.sprintStart)(identifier, options)));
886
893
  sprintCmd
887
894
  .command('close <identifier>')
888
- .description('Close an ACTIVE sprint. With no unfinished tasks, closes immediately. Pass --move-all --yes to move unfinished tasks to the next sprint, or --backlog-all --yes to return them to the backlog.')
895
+ .description('Close an ACTIVE sprint. With no unfinished tasks, closes immediately. Pass --move-all to move unfinished tasks to the next sprint, or --backlog-all to return them to the backlog; either mode without --confirm exits 4 with a confirmation envelope listing the tasks that would move.')
889
896
  .option('--team <ref>', 'Team name or UUID (informational)')
890
897
  .option('--move-all', 'Move all unfinished tasks to the next sprint')
891
898
  .option('--backlog-all', 'Return all unfinished tasks to the backlog')
892
- .option('--yes', 'Required when --move-all or --backlog-all is set')
899
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
900
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
893
901
  .action(wrap((identifier, options) => (0, sprint_close_1.sprintClose)(identifier, options)));
894
902
  sprintCmd
895
903
  .command('summary <identifier>')
@@ -979,7 +987,8 @@ doc
979
987
  .command('rebuild-source <doc>')
980
988
  .description("Regenerate the stored markdown source from the doc's HTML body using a lossless serializer (tables/rows/headings round-trip), re-enabling doc show --raw / diff / patch / append for a source-less doc. The rebuilt source is structure-guarded: any table/tr/heading shrink is rejected with 422 (no silent flattening) unless --allow-shrink is passed. A doc that already has a source is refused with 409 unless --force re-derives it (replacing a byte-faithful source with a serializer-derived one).")
981
989
  .option('--allow-shrink', 'Commit even if the rebuilt source re-renders with fewer tables/rows/headings than the stored body (default: rejected with 422)')
982
- .option('--force', 'Re-derive the source even when one already exists (default: refused with 409)')
990
+ .option('--force', 'Re-derive the source even when one already exists. Without it an existing source yields exit 4 + a confirmation envelope whose confirmCommand carries --force --confirm.')
991
+ .option('--confirm', 'The user has approved replacing the existing source (LUM-755); pair with --force as the envelope instructs')
983
992
  .option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
984
993
  .action(wrap((reference, opts) => (0, doc_rebuild_source_1.docRebuildSource)(reference, opts)));
985
994
  doc
@@ -999,8 +1008,9 @@ doc
999
1008
  .action(wrap((reference, opts) => (0, doc_move_1.docMove)(reference, opts)));
1000
1009
  doc
1001
1010
  .command('delete <doc>')
1002
- .description('Delete a document (requires --yes)')
1003
- .option('--yes', 'Confirm deletion')
1011
+ .description('Delete a document and everything nested under it. Without --confirm: exit 4 + a confirmation envelope with the title.')
1012
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
1013
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
1004
1014
  .action(wrap((reference, opts) => (0, doc_delete_1.docDelete)(reference, opts)));
1005
1015
  doc
1006
1016
  .command('bind <doc> <task>')
@@ -1041,6 +1051,7 @@ task
1041
1051
  .option('--add-tag-id <cuid>', 'Add tag by id (repeatable)', collect, [])
1042
1052
  .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, [])
1043
1053
  .option('--remove-tag-id <cuid>', 'Remove tag by id (repeatable). Unknown ids are a no-op.', collect, [])
1054
+ .option('--confirm', 'With --status done: the user has approved the DONE transition shown in the confirmation envelope (LUM-755). Without it, --status done exits 4 and prints the envelope (status change, every linked PR, a ⚠ line for unmerged ones) instead of a prompt.')
1044
1055
  .action(wrap((identifier, options) => (0, task_update_1.taskUpdate)(identifier, options)));
1045
1056
  const worktree = program
1046
1057
  .command('worktree')
@@ -1061,9 +1072,10 @@ worktree
1061
1072
  }));
1062
1073
  worktree
1063
1074
  .command('rm <identifier>')
1064
- .description('Remove the worktree for a task (git worktree remove). Requires --yes. Refuses a dirty worktree unless --force. Keeps the branch unless --delete-branch.')
1065
- .option('--yes', 'Confirm removal (required, no interactive prompt)')
1066
- .option('--force', 'Remove even with uncommitted changes')
1075
+ .description('Remove the worktree for a task (git worktree remove). Without --confirm or with uncommitted changes and no --force — exits 4 with a confirmation envelope naming the path, branch and what would be discarded. Keeps the branch unless --delete-branch.')
1076
+ .option('--confirm', 'The user has approved the change shown in the confirmation envelope (LUM-755). Without it the command exits 4 and prints what it would do.')
1077
+ .option('--yes', 'Alias of --confirm (kept for compatibility)')
1078
+ .option('--force', 'Remove even with uncommitted changes (discards them)')
1067
1079
  .option('--delete-branch', 'Also delete the branch (git branch -d; refuses if unmerged)')
1068
1080
  .action(wrap((identifier, options) => (0, worktree_rm_1.worktreeRm)(identifier, options)));
1069
1081
  worktree
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXIT_BLOCKED = void 0;
4
+ exports.isDoneGateRefusal = isDoneGateRefusal;
5
+ exports.collectDoneBlockers = collectDoneBlockers;
6
+ exports.buildRemediation = buildRemediation;
7
+ exports.buildBlockedError = buildBlockedError;
8
+ exports.renderBlocked = renderBlocked;
9
+ exports.emitBlocked = emitBlocked;
10
+ /**
11
+ * Structured "blocked by a human-only gate" error (LUM-755).
12
+ *
13
+ * The confirmation protocol (confirmation.ts, exit 4) covers mutations the
14
+ * USER can approve. Some refusals are different in kind: the server's DONE
15
+ * gates — an unresolved send-back, an undispositioned boundary crossing, a
16
+ * blocking security finding — are verdicts on the agent's own work, and the
17
+ * agent may not adjudicate itself: nothing on `task update` clears them.
18
+ * Since LUM-769 a boundary crossing does have a terminal path, but it is the
19
+ * USER's — `lumo crossing disposition` walks the same exit-4 protocol (the
20
+ * agent relays the crossing, the user rules, the agent re-runs with the
21
+ * approval flag); send-backs and security findings stay human-side / web.
22
+ * Echoing the 409 text and exiting 1 left the agent grepping prose to learn
23
+ * that. Instead the CLI now:
24
+ *
25
+ * 1. recognises the gate refusal (`isDoneGateRefusal`);
26
+ * 2. re-reads the existing read models — `GET …/status` for send-backs and
27
+ * blocking findings, `GET …/boundary-crossings` for open crossings — and
28
+ * lists every blocker with its id;
29
+ * 3. prints a `DONE_BLOCKED` error on stdout and exits 5
30
+ * (`EXIT_BLOCKED`), with remediation lines that only ever point at
31
+ * human-side or explain-only paths.
32
+ *
33
+ * A failed enrichment read is reported in `unconfirmed[]` rather than
34
+ * silently showing fewer blockers — the same fail-closed stance as
35
+ * `lumo task status`.
36
+ */
37
+ const open_crossings_1 = require("./open-crossings");
38
+ const sanitize_1 = require("./sanitize");
39
+ exports.EXIT_BLOCKED = 5;
40
+ const DONE_GATE_PREFIX = 'Cannot move this task to DONE';
41
+ /** All three server DONE gates share this message prefix. */
42
+ function isDoneGateRefusal(message) {
43
+ return typeof message === 'string' && message.startsWith(DONE_GATE_PREFIX);
44
+ }
45
+ async function collectDoneBlockers(args) {
46
+ const { base, token, identifier } = args;
47
+ const blockers = [];
48
+ const unconfirmed = [];
49
+ const headers = { Authorization: `Bearer ${token}` };
50
+ // Send-backs + blocking security findings: the acceptance-status read model.
51
+ try {
52
+ const res = await fetch(`${base}/api/tasks/${encodeURIComponent(identifier)}/status`, { headers });
53
+ if (!res.ok) {
54
+ unconfirmed.push(`task status read failed: HTTP ${res.status}`);
55
+ }
56
+ else {
57
+ const data = (await res.json());
58
+ for (const c of data.criteria ?? []) {
59
+ if (c.latestVerdict?.verdict === 'FAIL') {
60
+ blockers.push({
61
+ kind: 'SEND_BACK',
62
+ criterionId: c.id,
63
+ statement: c.statement,
64
+ failedAtRound: c.latestVerdict.round,
65
+ rejectionReason: c.latestVerdict.rejectionReason,
66
+ });
67
+ }
68
+ }
69
+ for (const a of data.nextActions ?? []) {
70
+ if (a.kind === 'SECURITY_FINDING' && a.blocking) {
71
+ blockers.push({
72
+ kind: 'SECURITY_FINDING',
73
+ findingId: a.findingId,
74
+ severity: a.severity,
75
+ ruleId: a.ruleId,
76
+ filePath: a.filePath,
77
+ line: a.line,
78
+ prNumber: a.prNumber,
79
+ statement: a.statement,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ catch (err) {
86
+ unconfirmed.push(`task status read failed: ${err instanceof Error ? err.message : String(err)}`);
87
+ }
88
+ // Open boundary crossings: the LUM-435 read model (fails closed, LUM-480).
89
+ const crossings = await (0, open_crossings_1.fetchOpenCrossings)(base, token, identifier);
90
+ if (crossings.status === 'error') {
91
+ unconfirmed.push(`boundary-crossings read failed: ${crossings.reason}`);
92
+ }
93
+ else {
94
+ for (const c of crossings.crossings) {
95
+ blockers.push({
96
+ kind: 'BOUNDARY_CROSSING',
97
+ id: c.id,
98
+ severity: c.severity,
99
+ category: c.category,
100
+ detail: c.detail,
101
+ });
102
+ }
103
+ }
104
+ return { blockers, unconfirmed };
105
+ }
106
+ /**
107
+ * One line per blocker kind present. Every line names a human-side path, the
108
+ * append-only `crossing explain`, or the user-approved `crossing disposition`
109
+ * (exit-4 protocol) — never a ready-to-run flag that would let the agent
110
+ * clear its own blocker on its own initiative.
111
+ */
112
+ function buildRemediation(blockers, unconfirmed, ctx) {
113
+ const kinds = new Set(blockers.map(b => b.kind));
114
+ const lines = [];
115
+ if (kinds.has('SEND_BACK')) {
116
+ lines.push(`SEND_BACK: fix the failed criterion in place and re-run \`lumo verify ${ctx.identifier}\`; a human can also record a PASS in the web verdict bar. Do not reword or delete the criterion to make it pass.`);
117
+ }
118
+ if (kinds.has('BOUNDARY_CROSSING')) {
119
+ const ids = blockers
120
+ .filter(b => b.kind === 'BOUNDARY_CROSSING')
121
+ .map(b => b.id);
122
+ lines.push(`BOUNDARY_CROSSING: leave a rationale with \`lumo crossing explain ${ids[0]} --note "…"\` (append-only; it does not clear anything), then relay each crossing to the user. Once they rule, record it with \`lumo crossing disposition ${ids[0]} --false-positive\` or \`--confirmed\` — it prints an exit-4 envelope for the user to approve; never self-approve. The web panel also works: ${ctx.dispositionUrl}`);
123
+ }
124
+ if (kinds.has('SECURITY_FINDING')) {
125
+ lines.push('SECURITY_FINDING: fix and push — a fixed fingerprint disappears from the next scan — or a human marks it false positive / accepted risk in the web delivery panel.');
126
+ }
127
+ if (unconfirmed.length > 0) {
128
+ lines.push(`Could not confirm the full blocker list (${unconfirmed.join('; ')}) — run \`lumo task status ${ctx.identifier}\` and check the task page before assuming the list above is complete.`);
129
+ }
130
+ return lines;
131
+ }
132
+ function buildBlockedError(args) {
133
+ const url = (0, open_crossings_1.dispositionUrl)(args.apiUrl, args.workspaceSlug, args.identifier);
134
+ return {
135
+ status: 'blocked',
136
+ code: 'DONE_BLOCKED',
137
+ command: 'task update',
138
+ task: args.identifier,
139
+ message: args.message,
140
+ blockers: args.blockers,
141
+ unconfirmed: args.unconfirmed,
142
+ remediation: buildRemediation(args.blockers, args.unconfirmed, {
143
+ identifier: args.identifier,
144
+ dispositionUrl: url,
145
+ }),
146
+ dispositionUrl: url,
147
+ };
148
+ }
149
+ function blockerLine(b) {
150
+ switch (b.kind) {
151
+ case 'SEND_BACK':
152
+ return `SEND_BACK ${b.criterionId} (round ${b.failedAtRound}): ${b.statement}${b.rejectionReason ? ` — ${b.rejectionReason}` : ''}`;
153
+ case 'BOUNDARY_CROSSING':
154
+ return `[${b.severity}] ${b.category} ${b.id}: ${b.detail}`;
155
+ case 'SECURITY_FINDING':
156
+ return `[${b.severity}] ${b.ruleId} ${b.findingId} — ${b.filePath}${b.line != null ? `:${b.line}` : ''} (PR #${b.prNumber}): ${b.statement}`;
157
+ }
158
+ }
159
+ /** JSON for pipes; the same fields as readable text for a terminal. */
160
+ function renderBlocked(err, isTTY) {
161
+ if (!isTTY)
162
+ return JSON.stringify(err, null, 2) + '\n';
163
+ const lines = [
164
+ `DONE_BLOCKED — \`lumo task update ${err.task} --status done\` was refused by a human-only gate.`,
165
+ ...err.message.split('\n').map(l => ` ${l}`),
166
+ '',
167
+ err.blockers.length > 0 ? 'Blockers:' : 'Blockers: (none listed)',
168
+ ...err.blockers.map(b => ` • ${(0, sanitize_1.sanitizeField)(blockerLine(b))}`),
169
+ ...(err.unconfirmed.length > 0
170
+ ? ['', ...err.unconfirmed.map(u => ` ⚠ ${u}`)]
171
+ : []),
172
+ '',
173
+ 'What can move it:',
174
+ ...err.remediation.map(r => ` - ${r}`),
175
+ ` Web disposition panel: ${err.dispositionUrl}`,
176
+ ];
177
+ return lines.join('\n') + '\n';
178
+ }
179
+ /** Print the structured error to stdout and return exit code 5. */
180
+ function emitBlocked(err) {
181
+ process.stdout.write(renderBlocked(err, Boolean(process.stdout.isTTY)));
182
+ return exports.EXIT_BLOCKED;
183
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBoundTask = resolveBoundTask;
4
+ async function resolveBoundTask(args) {
5
+ const explicit = args.explicit?.trim();
6
+ if (explicit)
7
+ return { ok: true, taskIdentifier: explicit };
8
+ if (!args.sessionId) {
9
+ return {
10
+ ok: false,
11
+ message: 'Error: $CLAUDE_CODE_SESSION_ID is not set — run inside a session bound via `lumo session attach <LUM-N>`, or pass --task <LUM-N>.',
12
+ };
13
+ }
14
+ let bound;
15
+ try {
16
+ const res = await fetch(`${args.base}/api/sessions/${encodeURIComponent(args.sessionId)}`, { headers: args.headers });
17
+ bound = res.ok
18
+ ? (await res.json())
19
+ : null;
20
+ }
21
+ catch (err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ return { ok: false, message: `Error: could not reach Lumo API (${msg})` };
24
+ }
25
+ if (!bound?.taskIdentifier) {
26
+ return {
27
+ ok: false,
28
+ message: 'Error: this session is not bound to a task. Run `lumo session attach <LUM-N>` first, or pass --task <LUM-N>.',
29
+ };
30
+ }
31
+ return { ok: true, taskIdentifier: bound.taskIdentifier };
32
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * Confirmation protocol (LUM-755).
4
+ *
5
+ * A mutation that needs human approval is neither an interactive
6
+ * "are you sure? [y/N]" (an agent has no stdin to answer it) nor a bare text
7
+ * refusal on stderr (exit 1 is indistinguishable from a real error, and the
8
+ * agent is left grepping prose). Instead the command:
9
+ *
10
+ * 1. computes, from real server/local state, what it is about to do;
11
+ * 2. prints a structured envelope to **stdout** and exits **4**
12
+ * (`EXIT_CONFIRMATION_REQUIRED`) without sending any mutation;
13
+ * 3. hands back a copy-pasteable `confirmCommand` — the same argv with
14
+ * `--confirm` appended (plus any second-gate flag such as `--force`).
15
+ *
16
+ * The agent shows `changes` to the user; only after the user approves does
17
+ * it re-run `confirmCommand` verbatim. `--confirm` therefore means "the user
18
+ * has confirmed" — it is not a flag an agent may add on its own initiative.
19
+ *
20
+ * Output shape: JSON when stdout is not a TTY (the agent path), a readable
21
+ * text rendering of the same fields on a TTY. There is no prompt in either
22
+ * mode — a human and an agent walk the same protocol.
23
+ *
24
+ * Exit codes (see `wrap()` in cli/src/index.ts): 0 ok · 1 error · 4 confirm.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.EXIT_CONFIRMATION_REQUIRED = void 0;
28
+ exports.isConfirmed = isConfirmed;
29
+ exports.shellQuote = shellQuote;
30
+ exports.buildConfirmCommand = buildConfirmCommand;
31
+ exports.buildEnvelope = buildEnvelope;
32
+ exports.renderConfirmation = renderConfirmation;
33
+ exports.emitConfirmation = emitConfirmation;
34
+ exports.EXIT_CONFIRMATION_REQUIRED = 4;
35
+ /** `--confirm`, or its legacy `--yes` alias. */
36
+ function isConfirmed(opts) {
37
+ return Boolean(opts.confirm || opts.yes);
38
+ }
39
+ /** POSIX-shell quoting: bare when safe, single-quoted otherwise. */
40
+ function shellQuote(arg) {
41
+ if (arg.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg))
42
+ return arg;
43
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
44
+ }
45
+ /** Flags that belong to a previous step of a handshake and must not carry
46
+ * over into the next step's command: the step flags themselves and a prior
47
+ * `--receipt <token>` pair (the next envelope brings its own receipt). */
48
+ const STEP_FLAGS = new Set(['--confirm', '--confirm-read']);
49
+ function stripStepFlags(argv) {
50
+ const out = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (STEP_FLAGS.has(a))
54
+ continue;
55
+ if (a === '--receipt') {
56
+ i++; // drop its value too
57
+ continue;
58
+ }
59
+ if (a.startsWith('--receipt='))
60
+ continue;
61
+ out.push(a);
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ * Rebuild the invocation the user ran, plus the approval flag (default
67
+ * `--confirm`) and any `extraFlags`. Step flags and a prior `--receipt` from
68
+ * an earlier handshake step are dropped first, flags already present in argv
69
+ * are never duplicated, and the approval flag always lands last so the
70
+ * command reads as "…, approved".
71
+ */
72
+ function buildConfirmCommand(argv = process.argv.slice(2), extraFlags = [], confirmFlag = '--confirm') {
73
+ const kept = stripStepFlags(argv);
74
+ const parts = ['lumo', ...kept.map(shellQuote)];
75
+ for (const flag of extraFlags) {
76
+ if (!kept.includes(flag))
77
+ parts.push(flag);
78
+ }
79
+ parts.push(confirmFlag);
80
+ return parts.join(' ');
81
+ }
82
+ function buildEnvelope(input) {
83
+ const extraFlags = [
84
+ ...(input.extraFlags ?? []),
85
+ ...(input.receipt ? ['--receipt', input.receipt.token] : []),
86
+ ];
87
+ return {
88
+ status: 'confirmation_required',
89
+ command: input.command,
90
+ changes: input.changes,
91
+ confirmCommand: buildConfirmCommand(input.argv, extraFlags, input.confirmFlag ?? '--confirm'),
92
+ ...(input.receipt ? { receipt: input.receipt } : {}),
93
+ };
94
+ }
95
+ /** JSON for pipes; the same fields as readable text for a terminal. */
96
+ function renderConfirmation(envelope, isTTY) {
97
+ if (!isTTY)
98
+ return JSON.stringify(envelope, null, 2) + '\n';
99
+ const lines = [
100
+ `Confirmation required — \`lumo ${envelope.command}\` was not run.`,
101
+ ...envelope.changes.map(c => ` - ${c}`),
102
+ ...(envelope.receipt
103
+ ? [`Read receipt issued (valid until ${envelope.receipt.expiresAt}).`]
104
+ : []),
105
+ `Re-run with ${envelope.confirmCommand.endsWith('--confirm-read') ? '--confirm-read' : '--confirm'} once the user has approved:`,
106
+ ` ${envelope.confirmCommand}`,
107
+ ];
108
+ return lines.join('\n') + '\n';
109
+ }
110
+ /**
111
+ * Print the envelope to stdout and return the exit code the command should
112
+ * propagate. Nothing goes to stderr: the envelope is the command's result,
113
+ * not an error.
114
+ */
115
+ function emitConfirmation(input) {
116
+ const envelope = buildEnvelope(input);
117
+ process.stdout.write(renderConfirmation(envelope, Boolean(process.stdout.isTTY)));
118
+ return exports.EXIT_CONFIRMATION_REQUIRED;
119
+ }