nawabari 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,7 @@ The supported standalone sequence is:
94
94
  ```text
95
95
  session create -> session claim(s) -> authorize/checkpoint
96
96
  -> commit/push -> doctor (reconciliation) -> session close/gc
97
+ -> explicit session discard only when the selected work is intentionally disposable
97
98
  ```
98
99
 
99
100
  The JSON envelope is one document on stdout. Success has `ok: true`, a
@@ -106,16 +107,17 @@ failure. Consumers must use these fields and codes, never human presentation.
106
107
 
107
108
  The result schemas expose the following identities:
108
109
 
109
- | Surface | Versioned identities |
110
- | ---------------------- | -------------------------------------------------------------------------------- |
111
- | session lifecycle | `session_id`, `repository`, `worktree`, `branch`, `state` |
112
- | claims | `claim_id`, `session_id`, `resource`, `mode` |
113
- | authorization | `operation`, `allowed`, `code`, `claim_ids` |
114
- | checkpoint evidence | `head`, `changed`, `staged`, `unstaged`, `untracked`, `in_claim`, `out_of_claim` |
115
- | repository evidence | `session_id`, `base_revision`, `head`, `clean`, `paths.stats`, `evidence_hash` |
116
- | bounded diff | `from_revision`, `to_revision`, `paths`, `stats`, `patch`, `evidence_hash` |
117
- | commit/push | `commit_sha`, `remote`, `branch`, `target`, `relation` |
118
- | reconciliation/cleanup | `clean`, `issues`, `candidates`, `cleaned`, `blocked`, `recovery_hints` |
110
+ | Surface | Versioned identities |
111
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
112
+ | session lifecycle | `session_id`, `repository`, `worktree`, `branch`, `state` |
113
+ | explicit discard | `previous_head`, `worktree_removed`, `branch_removed`, `released_claims`, `final_state`, final `session.state`/`terminal_operation` |
114
+ | claims | `claim_id`, `session_id`, `resource`, `mode` |
115
+ | authorization | `operation`, `allowed`, `code`, `claim_ids` |
116
+ | checkpoint evidence | `head`, `changed`, `staged`, `unstaged`, `untracked`, `in_claim`, `out_of_claim` |
117
+ | repository evidence | `session_id`, `base_revision`, `head`, `clean`, `paths.stats`, `evidence_hash` |
118
+ | bounded diff | `from_revision`, `to_revision`, `paths`, `stats`, `patch`, `evidence_hash` |
119
+ | commit/push | `commit_sha`, `remote`, `branch`, `target`, `relation` |
120
+ | reconciliation/cleanup | `clean`, `issues`, `candidates`, `cleaned`, `blocked`, `recovery_hints` |
119
121
 
120
122
  Git subprocesses are bounded at 10 seconds and 64 KiB of output; checkpoint
121
123
  evidence is bounded to 4,096 paths. `GIT_SPAWN_FAILED`, `GIT_TIMEOUT`,
@@ -162,24 +164,39 @@ machine identities; labels and branch names are separate display metadata.
162
164
  git nawabari session create --branch feature/example --worktree ../example-worktree --json
163
165
  git nawabari session id --json
164
166
  git nawabari session show --json
167
+ git nawabari session show <session-id> --json
165
168
  git nawabari session list --json
166
169
  git nawabari status --json
167
170
  git nawabari guard --json
168
171
  git nawabari session close --json
172
+ git nawabari session discard --session <session-id> --json
169
173
  git nawabari gc --dry-run --json
170
174
  git nawabari doctor --json
171
175
  ```
172
176
 
173
177
  `status --json` reports the resolved `managed_worktree_root` used when
174
- `session create` omits `--worktree`. `session create --help --json` describes
175
- all four create options as optional and reports defaults for branch, worktree,
176
- base (`HEAD`), and label.
178
+ `session create` omits `--worktree` and `--worktree-root`. `session create
179
+ --help --json` describes all create options as optional and reports defaults
180
+ for branch, worktree, worktree root, base (`HEAD`), and label.
181
+
182
+ `--worktree-root` selects only the parent directory for a new session
183
+ worktree; Nawabari still derives the final worktree basename from its own
184
+ session-naming contract. It is mutually exclusive with `--worktree`, the
185
+ exact-path override. Every session record's `worktree_root` field reports
186
+ the resolved parent of that session's worktree.
177
187
 
178
188
  `session create` provisions a dedicated worktree and mutable branch atomically
179
189
  under the repository-scoped mutation lock. The default/integration worktree
180
190
  and its protected branch cannot be session resources. `session id` and the
181
191
  other current-session commands resolve ownership from the current worktree;
182
- callers do not need to repeat the session ID.
192
+ callers do not need to repeat the session ID for current-owner operations.
193
+
194
+ Session-scoped commands use one target grammar: the canonical `--session <id>`
195
+ option is accepted everywhere, and `show`, `inspect`, `claim`, `claims`,
196
+ `release`, `update`, `close`, and `discard` also accept one positional
197
+ `<session-id>` immediately after the subcommand. Supplying both forms is
198
+ rejected as ambiguous. `session discard` always requires one explicit target
199
+ and never infers the current worktree owner.
183
200
 
184
201
  Close is conservative. Dirty worktrees, ambiguous ownership, mismatched Git
185
202
  state, and commits not proven reachable from the integration branch block
@@ -192,6 +209,17 @@ every candidate that is not safe. Cleanup revalidates the physical worktree,
192
209
  branch, and `HEAD` observations immediately before each destructive Git
193
210
  operation.
194
211
 
212
+ `session discard` is the sole explicit destructive abandonment path. It
213
+ revalidates repository identity, the selected session, exact worktree/branch
214
+ ownership, and session/worktree `HEAD` immediately before each Git mutation.
215
+ It may destroy the selected session's unintegrated commits and uncommitted
216
+ worktree contents, removes only that session's worktree/branch, releases only
217
+ its claims, and records `terminal_operation: "discard"` plus the pre-discard
218
+ `HEAD`. It never acts as an implicit fallback for `close`, `gc`, `doctor`, or
219
+ reconciliation; sibling sessions remain untouched. A partial failure leaves a
220
+ retryable closing record and a repeated discard converges or returns an
221
+ explicit terminal idempotent result.
222
+
195
223
  Routine `session list` and `status` output excludes `closed` history and is
196
224
  limited to 64 records. Use `--all` (or `--history`) for an explicit complete
197
225
  history view; closed records remain persisted and are never silently deleted
@@ -321,6 +349,54 @@ Detached, corrupt, missing, or conflicting state fails closed. The guard does
321
349
  not install hooks and does not prevent direct filesystem writes outside
322
350
  Nawabari.
323
351
 
352
+ ## Actionable lifecycle rejections
353
+
354
+ Stable codes are necessary but not sufficient for a caller to pick a safe
355
+ next step without a second, separate inspection. For the codes below,
356
+ Nawabari's `details` carry bounded, deterministic evidence and a
357
+ `safeActions`/`safe_actions` list of stable, kebab-case next-action
358
+ identifiers, in addition to the human-readable `message`. JSON and human
359
+ output always render the identical underlying result; only the formatting
360
+ differs.
361
+
362
+ - **`RESOURCE_CLAIM_CONFLICT`** (`session claim`/`session update`,
363
+ `authorize`, `guard --operation`) reports the blocking claim
364
+ (`ownerClaimId`, `ownerResource`, `ownerMode`) and the blocking session's
365
+ canonical identity (`ownerSessionId`, `ownerWorktree`, `ownerBranch`, and
366
+ `ownerLabel` when the session has one) in the same result, so a caller
367
+ never needs a second `session list` scan to find the owner.
368
+ - **`PROTECTED_WORKTREE`**/**`PROTECTED_BRANCH`** raised by a live command
369
+ (not by cleanup) add `phase: "execution"` and distinguish the current
370
+ (protected) execution context from the referenced `--session` target:
371
+ `requestedSessionId`, `targetWorktree`, `targetBranch`, and `targetState`
372
+ when that session exists. `safeActions` names the deterministic fix
373
+ (`run-from-managed-session-worktree`, `select-target-session-explicitly`)
374
+ instead of the unrelated cleanup-time hint.
375
+ - **`INVALID_SESSION_ID`** stays machine-ID based: an invalid `--session`
376
+ value is never silently reinterpreted as a label. When it exactly and
377
+ unambiguously matches one active session's label, the result adds
378
+ `session_id_hint` (the canonical session ID) and
379
+ `session_label_match: "unique"` as a non-authoritative hint. An ambiguous
380
+ or absent label match never guesses: `session_label_match` reports
381
+ `"ambiguous"` (with `session_label_match_count`) or `"none"` instead.
382
+ - **`RECOVERABLE_COMMITS`** raised by `session close` carries the same
383
+ `close_readiness`/`result_state` classification `session inspect` reports
384
+ for the identical state — `external_evidence_required` when ancestry alone
385
+ could not prove the branch safe and a `--integrated-revision` proof might
386
+ resolve it (e.g. after a squash/rebase merge), versus `blocked` when
387
+ supplied evidence failed to prove equivalence, versus `ambiguous` when Git
388
+ observation itself was inconclusive. For supplied evidence, bounded
389
+ `proofFailure` details distinguish an unauthoritative revision from a
390
+ tree-equivalence failure. The bounded evidence also includes
391
+ `currentSessionHead`, `suppliedIntegratedRevision`, `resolvedIntegrationSha`,
392
+ `lineageProof`/`authorityProof`, and `contentProof`. `safe_actions` includes
393
+ `discard-session` only as an explicit user choice; it does not authorize an
394
+ implicit cleanup fallback. Both surfaces reuse one authority, so a raw close
395
+ rejection and `session inspect` never drift apart.
396
+
397
+ None of the above weakens fail-closed behavior, changes an error code's
398
+ meaning, or performs any mutation while producing the rejection.
399
+
324
400
  ## Orchestrator integration
325
401
 
326
402
  An external orchestrator can create a session, capture the returned
package/dist/cli.js CHANGED
@@ -26,14 +26,18 @@ const HELP_COMMANDS = [
26
26
  value: "<name>",
27
27
  default: "nawabari/session/<session_id>",
28
28
  }),
29
- option("--worktree", "Managed worktree path; omitted uses the resolved repository-local root", {
29
+ option("--worktree", "Exact managed worktree path override; mutually exclusive with --worktree-root", {
30
30
  value: "<path>",
31
31
  default: "<managed_worktree_root>/<repository>-<session_id>",
32
32
  }),
33
+ option("--worktree-root", "Managed root to place the worktree under; Nawabari derives the final path. Mutually exclusive with --worktree", { value: "<path>", default: "resolved repository-local root" }),
33
34
  option("--base", "Commit-resolving base ref for the new worktree", { value: "<ref>", default: "HEAD" }),
34
35
  option("--label", "Optional display label; never used as an identity", { value: "<text>", default: "omitted" }),
35
36
  ],
36
- notes: ["All create options are optional. Use status --json to discover managed_worktree_root."],
37
+ notes: [
38
+ "All create options are optional. Use status --json to discover managed_worktree_root.",
39
+ "--worktree and --worktree-root cannot be combined.",
40
+ ],
37
41
  },
38
42
  {
39
43
  name: "session id",
@@ -44,8 +48,26 @@ const HELP_COMMANDS = [
44
48
  {
45
49
  name: "session show",
46
50
  summary: "Show the current or selected session",
47
- usage: `${CLI_NAME} session show [--session <id>]`,
51
+ usage: `${CLI_NAME} session show [<session-id>|--session <id>]`,
48
52
  options: [option("--session", "Select a session instead of the current worktree owner", { value: "<id>" })],
53
+ notes: [
54
+ "Target grammar is consistent: an optional first positional <session-id> is an alias for --session <id>; do not supply both.",
55
+ ],
56
+ },
57
+ {
58
+ name: "session inspect",
59
+ summary: "Report side-effect-free close/cleanup readiness for a session",
60
+ usage: `${CLI_NAME} session inspect [<session-id>|--session <id>] [--integrated-revision <rev>]`,
61
+ options: [
62
+ option("--session", "Select a session instead of the current worktree owner", { value: "<id>" }),
63
+ option("--integrated-revision", "Externally evidenced revision to test for non-ancestry (squash/rebase) integration; independently re-verified via exact Git tree-object equivalence, never trusted blindly", { value: "<rev>" }),
64
+ ],
65
+ notes: [
66
+ "Read-only: never mutates session, claim, Git, worktree, branch, or registry state. Repeated calls are idempotent.",
67
+ "Derived from the same authoritative close/cleanup Git evidence as session close; does not duplicate or diverge from that logic.",
68
+ "Nawabari never queries GitHub or any remote provider; --integrated-revision only names a local revision for Nawabari to independently verify.",
69
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
70
+ ],
49
71
  },
50
72
  {
51
73
  name: "session run",
@@ -70,18 +92,21 @@ const HELP_COMMANDS = [
70
92
  {
71
93
  name: "session claim",
72
94
  summary: "Add a canonical resource claim",
73
- usage: `${CLI_NAME} session claim --resource <path-or-glob> --mode <read|write|exclusive-write> [--session <id>]`,
95
+ usage: `${CLI_NAME} session claim [<session-id>] --resource <path-or-glob> --mode <read|write|exclusive-write>`,
74
96
  options: [
75
97
  option("--resource", "Repository-relative resource", { value: "<path-or-glob>", required: true }),
76
98
  option("--mode", "Granted claim mode", { value: "<read|write|exclusive-write>", required: true }),
77
99
  option("--session", "Target active session; omitted resolves the current owner", { value: "<id>" }),
78
100
  option("--repository", "Expected repository identity", { value: "<id>" }),
79
101
  ],
102
+ notes: [
103
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
104
+ ],
80
105
  },
81
106
  {
82
107
  name: "session update",
83
108
  summary: "Atomically replace a session's complete resource claim set",
84
- usage: `${CLI_NAME} session update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
109
+ usage: `${CLI_NAME} session update [<session-id>] --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...]`,
85
110
  options: [
86
111
  option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
87
112
  value: "<path-or-glob>",
@@ -98,33 +123,43 @@ const HELP_COMMANDS = [
98
123
  "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
99
124
  "on any invalid or conflicting claim the prior set is left unchanged.",
100
125
  "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
126
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
101
127
  ],
102
128
  },
103
129
  {
104
130
  name: "session claims",
105
131
  summary: "List canonical resource claims",
106
- usage: `${CLI_NAME} session claims [--session <id>]`,
132
+ usage: `${CLI_NAME} session claims [<session-id>|--session <id>]`,
107
133
  options: [option("--session", "Select a session; omitted lists all claims", { value: "<id>" })],
134
+ notes: [
135
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
136
+ ],
108
137
  },
109
138
  {
110
139
  name: "session release",
111
140
  summary: "Release resource claims",
112
- usage: `${CLI_NAME} session release [--session <id>] [--claim-id <id>]`,
141
+ usage: `${CLI_NAME} session release [<session-id>|--session <id>] [--claim-id <id>]`,
113
142
  options: [
114
143
  option("--session", "Target session; omitted resolves the current owner", { value: "<id>" }),
115
144
  option("--claim-id", "Release only one claim; omitted releases all owned claims", { value: "<id>" }),
116
145
  ],
146
+ notes: [
147
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
148
+ ],
117
149
  },
118
150
  {
119
151
  name: "resource claim",
120
152
  summary: "Add a canonical resource claim (alias)",
121
- usage: `${CLI_NAME} resource claim --resource <path-or-glob> --mode <read|write|exclusive-write>`,
153
+ usage: `${CLI_NAME} resource claim [<session-id>|--session <id>] --resource <path-or-glob> --mode <read|write|exclusive-write>`,
122
154
  options: [],
155
+ notes: [
156
+ "Target grammar matches session claim: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
157
+ ],
123
158
  },
124
159
  {
125
160
  name: "resource update",
126
161
  summary: "Atomically replace a session's complete resource claim set (alias)",
127
- usage: `${CLI_NAME} resource update --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...] [--session <id>]`,
162
+ usage: `${CLI_NAME} resource update [<session-id>|--session <id>] --resource <path-or-glob> --mode <read|write|exclusive-write> [--resource <path-or-glob> --mode <read|write|exclusive-write> ...]`,
128
163
  options: [
129
164
  option("--resource", "Repository-relative resource; repeatable, each paired with the --mode immediately after it", {
130
165
  value: "<path-or-glob>",
@@ -141,28 +176,60 @@ const HELP_COMMANDS = [
141
176
  "The desired claim set fully replaces the session's current claims in one updateClaims() transaction; " +
142
177
  "on any invalid or conflicting claim the prior set is left unchanged.",
143
178
  "Each --resource must be immediately followed by its own --mode; pairing is positional adjacency, not flag order.",
179
+ "Target grammar matches session update: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
144
180
  ],
145
181
  },
146
182
  {
147
183
  name: "resource list",
148
184
  summary: "List canonical resource claims (alias)",
149
- usage: `${CLI_NAME} resource list [--session <id>]`,
185
+ usage: `${CLI_NAME} resource list [<session-id>|--session <id>]`,
150
186
  options: [option("--session", "Select a session; omitted lists all claims", { value: "<id>" })],
187
+ notes: [
188
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
189
+ ],
151
190
  },
152
191
  {
153
192
  name: "resource release",
154
193
  summary: "Release resource claims (alias)",
155
- usage: `${CLI_NAME} resource release [--session <id>] [--claim-id <id>]`,
194
+ usage: `${CLI_NAME} resource release [<session-id>|--session <id>] [--claim-id <id>]`,
156
195
  options: [
157
196
  option("--session", "Target session; omitted resolves the current owner", { value: "<id>" }),
158
197
  option("--claim-id", "Release only one claim; omitted releases all owned claims", { value: "<id>" }),
159
198
  ],
199
+ notes: [
200
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
201
+ ],
160
202
  },
161
203
  {
162
204
  name: "session close",
163
205
  summary: "Close the current or selected session",
164
- usage: `${CLI_NAME} session close [--session <id>]`,
165
- options: [option("--session", "Select a session instead of the current worktree owner", { value: "<id>" })],
206
+ usage: `${CLI_NAME} session close [<session-id>|--session <id>] [--integrated-revision <rev>]`,
207
+ options: [
208
+ option("--session", "Select a session instead of the current worktree owner", { value: "<id>" }),
209
+ option("--integrated-revision", "Externally evidenced revision proving non-ancestry (squash/rebase) integration; independently re-verified via exact Git tree-object equivalence, never trusted blindly", { value: "<rev>" }),
210
+ ],
211
+ notes: [
212
+ "Ordinary ancestry-based close remains the cheap/default path and requires no flags.",
213
+ "Nawabari never queries GitHub or any remote provider; --integrated-revision only names a local revision for Nawabari to independently verify.",
214
+ "Target grammar: optional first positional <session-id> is an alias for --session <id>; do not supply both.",
215
+ ],
216
+ },
217
+ {
218
+ name: "session discard",
219
+ summary: "Explicitly discard one selected session and its owned resources",
220
+ usage: `${CLI_NAME} session discard <session-id>|--session <id>`,
221
+ options: [
222
+ option("--session", "Required explicit target; the current session is never inferred", {
223
+ value: "<id>",
224
+ required: true,
225
+ }),
226
+ ],
227
+ notes: [
228
+ "Destructive and explicit: unintegrated commits and uncommitted work in the selected owned worktree may be destroyed.",
229
+ "Discard never changes close, gc, doctor, reconciliation, or integration-lineage proof behavior.",
230
+ "Use exactly one target form: positional <session-id> or --session <id>; do not supply both.",
231
+ "Machine JSON mode is non-interactive and deterministic.",
232
+ ],
166
233
  },
167
234
  {
168
235
  name: "authorize",
@@ -322,6 +389,13 @@ function helpPayload(spec) {
322
389
  commit_options: optionsFor("commit"),
323
390
  push_options: optionsFor("push"),
324
391
  gc_options: optionsFor("gc"),
392
+ session_targeting: {
393
+ canonical: "--session <id>",
394
+ positional_alias: "<session-id> as the first argument after a session-scoped subcommand",
395
+ commands: ["show", "inspect", "claim", "claims", "release", "update", "close", "discard"],
396
+ ambiguity: "supplying both positional and --session is rejected",
397
+ discard_requires_explicit_target: true,
398
+ },
325
399
  };
326
400
  }
327
401
  const options = spec.options.map((candidate) => ({
@@ -431,6 +505,7 @@ function parseOptions(arguments_, allowed) {
431
505
  session_id: null,
432
506
  branch: null,
433
507
  worktree: null,
508
+ worktree_root: null,
434
509
  base: null,
435
510
  label: null,
436
511
  resource: null,
@@ -456,6 +531,7 @@ function parseOptions(arguments_, allowed) {
456
531
  patch: false,
457
532
  max_bytes: null,
458
533
  max_hunks: null,
534
+ integrated_revision: null,
459
535
  };
460
536
  let dryRun = false;
461
537
  for (let index = 0; index < arguments_.length; index += 1) {
@@ -501,6 +577,8 @@ function parseOptions(arguments_, allowed) {
501
577
  options.branch = value;
502
578
  else if (name === "--worktree")
503
579
  options.worktree = value;
580
+ else if (name === "--worktree-root")
581
+ options.worktree_root = value;
504
582
  else if (name === "--base")
505
583
  options.base = value;
506
584
  else if (name === "--label")
@@ -539,6 +617,8 @@ function parseOptions(arguments_, allowed) {
539
617
  options.max_bytes = value;
540
618
  else if (name === "--max-hunks")
541
619
  options.max_hunks = value;
620
+ else if (name === "--integrated-revision")
621
+ options.integrated_revision = value;
542
622
  }
543
623
  if (options.apply && dryRun) {
544
624
  return failure(usageError("INVALID_ARGUMENT", "--apply and --dry-run cannot be used together."));
@@ -548,6 +628,34 @@ function parseOptions(arguments_, allowed) {
548
628
  function noOptions(arguments_) {
549
629
  return parseOptions(arguments_, new Set());
550
630
  }
631
+ /** One unambiguous positional alias shared by every session-targeted command. */
632
+ function splitPositionalSessionTarget(arguments_) {
633
+ const first = arguments_[0];
634
+ if (first === undefined || first.startsWith("-")) {
635
+ return { ok: true, value: { sessionId: null, arguments: arguments_ } };
636
+ }
637
+ return { ok: true, value: { sessionId: first, arguments: arguments_.slice(1) } };
638
+ }
639
+ function parseTargetedOptions(arguments_, allowed, required = false) {
640
+ const positional = splitPositionalSessionTarget(arguments_);
641
+ if (!positional.ok)
642
+ return positional;
643
+ const parsed = parseOptions(positional.value.arguments, allowed);
644
+ if (!parsed.ok)
645
+ return parsed;
646
+ if (positional.value.sessionId !== null && parsed.value.session_id !== null) {
647
+ return failure(usageError("INVALID_ARGUMENT", "Specify a session target either positionally or with --session, not both.", {
648
+ option: "--session",
649
+ }));
650
+ }
651
+ parsed.value.session_id = positional.value.sessionId ?? parsed.value.session_id;
652
+ if (required && parsed.value.session_id === null) {
653
+ return failure(usageError("MISSING_ARGUMENT", "An explicit session target is required; use <session-id> or --session <id>.", {
654
+ option: "--session",
655
+ }));
656
+ }
657
+ return parsed;
658
+ }
551
659
  /**
552
660
  * `update` accepts a complete desired claim set as repeated
553
661
  * `--resource <path> --mode <mode>` pairs. Pairing is by strict local
@@ -557,26 +665,35 @@ function noOptions(arguments_) {
557
665
  * with the wrong mode.
558
666
  */
559
667
  function parseClaimReplacementPairs(arguments_) {
668
+ const positional = splitPositionalSessionTarget(arguments_);
669
+ if (!positional.ok)
670
+ return positional;
560
671
  let sessionId = null;
561
672
  let repository = null;
562
673
  const pairs = [];
563
674
  let pendingResource = null;
564
- for (let index = 0; index < arguments_.length; index += 1) {
565
- const { name, inlineValue } = optionParts(arguments_[index]);
675
+ sessionId = positional.value.sessionId;
676
+ const targetArguments = positional.value.arguments;
677
+ for (let index = 0; index < targetArguments.length; index += 1) {
678
+ const { name, inlineValue } = optionParts(targetArguments[index]);
566
679
  if (name !== "--session" && name !== "--repository" && name !== "--resource" && name !== "--mode") {
567
680
  return failure(usageError("INVALID_ARGUMENT", `Unknown option: ${name}.`, { option: name }));
568
681
  }
569
682
  if (pendingResource !== null && name !== "--mode") {
570
683
  return failure(usageError("INVALID_ARGUMENT", `${name} cannot appear between --resource and its --mode; only --mode is permitted immediately after --resource.`, { option: name }));
571
684
  }
572
- const value = inlineValue ?? arguments_[index + 1];
685
+ const value = inlineValue ?? targetArguments[index + 1];
573
686
  if (value === undefined || value === "" || (inlineValue === null && value.startsWith("-"))) {
574
687
  return failure(usageError("MISSING_ARGUMENT", `${name} requires a value.`, { option: name }));
575
688
  }
576
689
  if (inlineValue === null)
577
690
  index += 1;
578
- if (name === "--session")
691
+ if (name === "--session") {
692
+ if (sessionId !== null) {
693
+ return failure(usageError("INVALID_ARGUMENT", "Specify a session target either positionally or with --session, not both."));
694
+ }
579
695
  sessionId = value;
696
+ }
580
697
  else if (name === "--repository")
581
698
  repository = value;
582
699
  else if (name === "--resource")
@@ -714,7 +831,7 @@ async function executeCommand(commandArguments, dependencies) {
714
831
  return result.ok ? { ok: true, value: result.value } : result;
715
832
  }
716
833
  if (subcommand === "claims") {
717
- const parsed = parseOptions(rest, new Set(["--session"]));
834
+ const parsed = parseTargetedOptions(rest, new Set(["--session"]));
718
835
  if (!parsed.ok)
719
836
  return parsed;
720
837
  if (dependencies.backend.listClaims === undefined)
@@ -723,7 +840,7 @@ async function executeCommand(commandArguments, dependencies) {
723
840
  return result.ok ? { ok: true, value: result.value } : result;
724
841
  }
725
842
  if (subcommand === "release") {
726
- const parsed = parseOptions(rest, new Set(["--session", "--claim-id"]));
843
+ const parsed = parseTargetedOptions(rest, new Set(["--session", "--claim-id"]));
727
844
  if (!parsed.ok)
728
845
  return parsed;
729
846
  if (dependencies.backend.releaseClaims === undefined)
@@ -735,12 +852,16 @@ async function executeCommand(commandArguments, dependencies) {
735
852
  return result.ok ? { ok: true, value: result.value } : result;
736
853
  }
737
854
  if (subcommand === "create") {
738
- const parsed = parseOptions(rest, new Set(["--branch", "--worktree", "--base", "--label"]));
855
+ const parsed = parseOptions(rest, new Set(["--branch", "--worktree", "--worktree-root", "--base", "--label"]));
739
856
  if (!parsed.ok)
740
857
  return parsed;
858
+ if (parsed.value.worktree !== null && parsed.value.worktree_root !== null) {
859
+ return failure(usageError("INVALID_ARGUMENT", "--worktree and --worktree-root cannot be used together."));
860
+ }
741
861
  const options = {
742
862
  branch: parsed.value.branch,
743
863
  worktree: parsed.value.worktree,
864
+ worktree_root: parsed.value.worktree_root,
744
865
  base: parsed.value.base,
745
866
  label: parsed.value.label,
746
867
  };
@@ -769,6 +890,28 @@ async function executeCommand(commandArguments, dependencies) {
769
890
  });
770
891
  return result.ok ? { ok: true, value: result.value } : result;
771
892
  }
893
+ if (subcommand === "inspect") {
894
+ const parsed = parseTargetedOptions(rest, new Set(["--session", "--integrated-revision"]));
895
+ if (!parsed.ok)
896
+ return parsed;
897
+ if (dependencies.backend.sessionDiagnostic === undefined)
898
+ return sessionDiagnosticCapabilityUnavailable();
899
+ const options = {
900
+ session_id: parsed.value.session_id,
901
+ integrated_revision: parsed.value.integrated_revision,
902
+ };
903
+ const result = await dependencies.backend.sessionDiagnostic(context, options);
904
+ return result.ok ? { ok: true, value: result.value } : result;
905
+ }
906
+ if (subcommand === "discard") {
907
+ const parsed = parseTargetedOptions(rest, new Set(["--session"]), true);
908
+ if (!parsed.ok)
909
+ return parsed;
910
+ if (dependencies.backend.discardSession === undefined)
911
+ return sessionDiscardCapabilityUnavailable();
912
+ const result = await dependencies.backend.discardSession(context, parsed.value.session_id);
913
+ return result.ok ? { ok: true, value: result.value } : result;
914
+ }
772
915
  if (subcommand === "id" || subcommand === "show" || subcommand === "close") {
773
916
  if (subcommand === "id") {
774
917
  const parsed = noOptions(rest);
@@ -779,11 +922,14 @@ async function executeCommand(commandArguments, dependencies) {
779
922
  return selected;
780
923
  return { ok: true, value: { session_id: selected.value.session_id } };
781
924
  }
782
- const parsed = parseOptions(rest, new Set(["--session"]));
925
+ const parsed = parseTargetedOptions(rest, new Set(subcommand === "close" ? ["--session", "--integrated-revision"] : ["--session"]));
783
926
  if (!parsed.ok)
784
927
  return parsed;
785
928
  if (subcommand === "close") {
786
- const closeOptions = { session_id: parsed.value.session_id };
929
+ const closeOptions = {
930
+ session_id: parsed.value.session_id,
931
+ integrated_revision: parsed.value.integrated_revision,
932
+ };
787
933
  const selected = await dependencies.backend.closeSession(context, closeOptions);
788
934
  return selected.ok ? { ok: true, value: selected.value } : selected;
789
935
  }
@@ -834,7 +980,7 @@ async function executeCommand(commandArguments, dependencies) {
834
980
  return result.ok ? { ok: true, value: result.value } : result;
835
981
  }
836
982
  if (resourceSubcommand === "list" || resourceSubcommand === "claims") {
837
- const parsed = parseOptions(rest, new Set(["--session"]));
983
+ const parsed = parseTargetedOptions(rest, new Set(["--session"]));
838
984
  if (!parsed.ok)
839
985
  return parsed;
840
986
  if (dependencies.backend.listClaims === undefined)
@@ -843,7 +989,7 @@ async function executeCommand(commandArguments, dependencies) {
843
989
  return result.ok ? { ok: true, value: result.value } : result;
844
990
  }
845
991
  if (resourceSubcommand === "release") {
846
- const parsed = parseOptions(rest, new Set(["--session", "--claim-id"]));
992
+ const parsed = parseTargetedOptions(rest, new Set(["--session", "--claim-id"]));
847
993
  if (!parsed.ok)
848
994
  return parsed;
849
995
  if (dependencies.backend.releaseClaims === undefined)
@@ -1095,6 +1241,16 @@ function checkpointCapabilityUnavailable() {
1095
1241
  operation: "checkpoint",
1096
1242
  }));
1097
1243
  }
1244
+ function sessionDiagnosticCapabilityUnavailable() {
1245
+ return failure(new DomainError("BACKEND_UNAVAILABLE", "Session diagnostic capability is not available.", {
1246
+ operation: "session.inspect",
1247
+ }));
1248
+ }
1249
+ function sessionDiscardCapabilityUnavailable() {
1250
+ return failure(new DomainError("BACKEND_UNAVAILABLE", "Session discard capability is not available.", {
1251
+ operation: "session.discard",
1252
+ }));
1253
+ }
1098
1254
  function repositoryEvidenceCapabilityUnavailable(operation) {
1099
1255
  return failure(new DomainError("BACKEND_UNAVAILABLE", "Repository evidence capability is not available.", { operation }));
1100
1256
  }
@@ -1130,6 +1286,57 @@ function emitFailure(mode, command, error, io) {
1130
1286
  io[mode === "json" ? "stdout" : "stderr"](renderFailure(mode, command, error));
1131
1287
  return error.exitCode;
1132
1288
  }
1289
+ /** Extract the raw `--session` value a rejected INVALID_SESSION_ID carried, regardless of which command path threw it. */
1290
+ function invalidSessionIdQuery(error) {
1291
+ const details = error.details;
1292
+ if (details === null)
1293
+ return null;
1294
+ if (typeof details.sessionId === "string")
1295
+ return details.sessionId;
1296
+ const nested = details.details;
1297
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested))
1298
+ return null;
1299
+ const nestedSessionId = nested.sessionId;
1300
+ return typeof nestedSessionId === "string" ? nestedSessionId : null;
1301
+ }
1302
+ /**
1303
+ * `--session` stays machine-ID based: an invalid value is never silently
1304
+ * reinterpreted as a label. But when it exactly and unambiguously matches
1305
+ * one active session's label, expose that session's canonical ID as a
1306
+ * bounded, non-authoritative hint instead of forcing a separate `session
1307
+ * list` round trip. Ambiguous or absent matches never guess.
1308
+ */
1309
+ async function enrichInvalidSessionIdError(error, backend, context) {
1310
+ if (error.code !== "INVALID_SESSION_ID")
1311
+ return error;
1312
+ const query = invalidSessionIdQuery(error);
1313
+ if (query === null || query.length === 0)
1314
+ return error;
1315
+ const listing = await backend.listSessions(context, { include_closed: false, limit: MAX_SESSION_LIST_LIMIT });
1316
+ if (!listing.ok)
1317
+ return error;
1318
+ const matches = listing.value.sessions.filter((session) => session.state === "active" && session.label === query);
1319
+ const hint = matches.length === 1
1320
+ ? {
1321
+ session_label_query: query,
1322
+ session_label_match: "unique",
1323
+ session_id_hint: matches[0].session_id,
1324
+ safe_actions: ["retry-with-session-id-hint"],
1325
+ }
1326
+ : matches.length > 1
1327
+ ? {
1328
+ session_label_query: query,
1329
+ session_label_match: "ambiguous",
1330
+ session_label_match_count: matches.length,
1331
+ safe_actions: ["disambiguate-session-label", "list-sessions"],
1332
+ }
1333
+ : {
1334
+ session_label_query: query,
1335
+ session_label_match: "none",
1336
+ safe_actions: ["list-sessions"],
1337
+ };
1338
+ return new DomainError(error.code, error.message, { ...error.details, ...hint }, error.exitCode);
1339
+ }
1133
1340
  export async function runCli(argv, dependencies = {}) {
1134
1341
  const io = dependencies.io ?? defaultCliIO();
1135
1342
  const delimiter = argv.indexOf("--");
@@ -1182,8 +1389,10 @@ export async function runCli(argv, dependencies = {}) {
1182
1389
  sandboxProbe: dependencies.sandboxProbe,
1183
1390
  sandboxRuntimeLayout: dependencies.sandboxRuntimeLayout,
1184
1391
  });
1185
- if (!result.ok)
1186
- return emitFailure(mode, command, result.error, io);
1392
+ if (!result.ok) {
1393
+ const enriched = await enrichInvalidSessionIdError(result.error, backend, sessionContext(cwd));
1394
+ return emitFailure(mode, command, enriched, io);
1395
+ }
1187
1396
  io.stdout(renderSuccess(mode, command, result.value));
1188
1397
  if ((command === "session run" || command === "session exec") &&
1189
1398
  typeof result.value.exit_code === "number" &&