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 +90 -14
- package/dist/cli.js +235 -26
- package/dist/cli.js.map +1 -1
- package/dist/contract.js +92 -0
- package/dist/contract.js.map +1 -1
- package/dist/domain/sandbox-launcher.js +3 -3
- package/dist/domain/sandbox-launcher.js.map +1 -1
- package/dist/domain/session-backend.d.ts +3 -1
- package/dist/domain/session-backend.js +104 -1
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +80 -0
- package/dist/domain/session.js +3 -0
- package/dist/domain/session.js.map +1 -1
- package/dist/git.d.ts +25 -0
- package/dist/git.js +75 -0
- package/dist/git.js.map +1 -1
- package/dist/session-registry.d.ts +140 -0
- package/dist/session-registry.js +675 -50
- package/dist/session-registry.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
|
118
|
-
|
|
|
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
|
|
175
|
-
all
|
|
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", "
|
|
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: [
|
|
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 [
|
|
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
|
|
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> ...]
|
|
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 [
|
|
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 [
|
|
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> ...]
|
|
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 [
|
|
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 [
|
|
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 [
|
|
165
|
-
options: [
|
|
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
|
-
|
|
565
|
-
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 = {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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" &&
|