@paths.design/caws-cli 11.1.7 → 11.1.8

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 (158) hide show
  1. package/dist/index.js +55 -58
  2. package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
  3. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
  4. package/dist/init/hook-packs/manifest-claude-code.js +260 -2
  5. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
  6. package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
  7. package/dist/shell/binding/resolve-binding.js +105 -1
  8. package/dist/shell/binding/resolve-binding.js.map +1 -1
  9. package/dist/shell/binding/types.d.ts +47 -3
  10. package/dist/shell/binding/types.d.ts.map +1 -1
  11. package/dist/shell/command-metadata.d.ts +93 -0
  12. package/dist/shell/command-metadata.d.ts.map +1 -0
  13. package/dist/shell/command-metadata.js +687 -0
  14. package/dist/shell/command-metadata.js.map +1 -0
  15. package/dist/shell/commands/agents.d.ts +1 -2
  16. package/dist/shell/commands/agents.d.ts.map +1 -1
  17. package/dist/shell/commands/claim.d.ts +16 -0
  18. package/dist/shell/commands/claim.d.ts.map +1 -1
  19. package/dist/shell/commands/claim.js +85 -26
  20. package/dist/shell/commands/claim.js.map +1 -1
  21. package/dist/shell/commands/events.d.ts +106 -0
  22. package/dist/shell/commands/events.d.ts.map +1 -0
  23. package/dist/shell/commands/events.js +510 -0
  24. package/dist/shell/commands/events.js.map +1 -0
  25. package/dist/shell/commands/gates.d.ts +2 -2
  26. package/dist/shell/commands/gates.d.ts.map +1 -1
  27. package/dist/shell/commands/gates.js +106 -25
  28. package/dist/shell/commands/gates.js.map +1 -1
  29. package/dist/shell/commands/init.d.ts.map +1 -1
  30. package/dist/shell/commands/init.js +26 -0
  31. package/dist/shell/commands/init.js.map +1 -1
  32. package/dist/shell/commands/prepush.d.ts +26 -0
  33. package/dist/shell/commands/prepush.d.ts.map +1 -0
  34. package/dist/shell/commands/prepush.js +373 -0
  35. package/dist/shell/commands/prepush.js.map +1 -0
  36. package/dist/shell/commands/scope.d.ts.map +1 -1
  37. package/dist/shell/commands/scope.js +31 -1
  38. package/dist/shell/commands/scope.js.map +1 -1
  39. package/dist/shell/commands/specs.d.ts +44 -3
  40. package/dist/shell/commands/specs.d.ts.map +1 -1
  41. package/dist/shell/commands/specs.js +411 -15
  42. package/dist/shell/commands/specs.js.map +1 -1
  43. package/dist/shell/commands/worktree.d.ts.map +1 -1
  44. package/dist/shell/commands/worktree.js +51 -1
  45. package/dist/shell/commands/worktree.js.map +1 -1
  46. package/dist/shell/gates/disposition.d.ts.map +1 -1
  47. package/dist/shell/gates/disposition.js +43 -2
  48. package/dist/shell/gates/disposition.js.map +1 -1
  49. package/dist/shell/index.d.ts +10 -4
  50. package/dist/shell/index.d.ts.map +1 -1
  51. package/dist/shell/index.js +22 -2
  52. package/dist/shell/index.js.map +1 -1
  53. package/dist/shell/legacy-command-map.js +832 -0
  54. package/dist/shell/push-range/classify-range.d.ts +99 -0
  55. package/dist/shell/push-range/classify-range.d.ts.map +1 -0
  56. package/dist/shell/push-range/classify-range.js +155 -0
  57. package/dist/shell/push-range/classify-range.js.map +1 -0
  58. package/dist/shell/push-range/scope-match.d.ts +13 -0
  59. package/dist/shell/push-range/scope-match.d.ts.map +1 -0
  60. package/dist/shell/push-range/scope-match.js +53 -0
  61. package/dist/shell/push-range/scope-match.js.map +1 -0
  62. package/dist/shell/register.d.ts.map +1 -1
  63. package/dist/shell/register.js +263 -228
  64. package/dist/shell/register.js.map +1 -1
  65. package/dist/shell/registered-command-groups.js +48 -0
  66. package/dist/shell/rules.d.ts +19 -0
  67. package/dist/shell/rules.d.ts.map +1 -1
  68. package/dist/shell/rules.js +27 -0
  69. package/dist/shell/rules.js.map +1 -1
  70. package/dist/shell/session/resolve-session.d.ts +29 -1
  71. package/dist/shell/session/resolve-session.d.ts.map +1 -1
  72. package/dist/shell/session/resolve-session.js +817 -11
  73. package/dist/shell/session/resolve-session.js.map +1 -1
  74. package/dist/shell/session/types.d.ts +127 -1
  75. package/dist/shell/session/types.d.ts.map +1 -1
  76. package/dist/shell/session/types.js +10 -4
  77. package/dist/shell/session/types.js.map +1 -1
  78. package/dist/store/doctor-snapshot.d.ts.map +1 -1
  79. package/dist/store/doctor-snapshot.js +26 -0
  80. package/dist/store/doctor-snapshot.js.map +1 -1
  81. package/dist/store/events-migration.d.ts +207 -0
  82. package/dist/store/events-migration.d.ts.map +1 -0
  83. package/dist/store/events-migration.js +358 -0
  84. package/dist/store/events-migration.js.map +1 -0
  85. package/dist/store/events-store.d.ts +47 -1
  86. package/dist/store/events-store.d.ts.map +1 -1
  87. package/dist/store/events-store.js +278 -0
  88. package/dist/store/events-store.js.map +1 -1
  89. package/dist/store/git-autocommit.d.ts +46 -0
  90. package/dist/store/git-autocommit.d.ts.map +1 -0
  91. package/dist/store/git-autocommit.js +198 -0
  92. package/dist/store/git-autocommit.js.map +1 -0
  93. package/dist/store/index.d.ts +4 -1
  94. package/dist/store/index.d.ts.map +1 -1
  95. package/dist/store/index.js +7 -1
  96. package/dist/store/index.js.map +1 -1
  97. package/dist/store/leases-store.d.ts.map +1 -1
  98. package/dist/store/leases-store.js +58 -0
  99. package/dist/store/leases-store.js.map +1 -1
  100. package/dist/store/rules.d.ts +53 -0
  101. package/dist/store/rules.d.ts.map +1 -1
  102. package/dist/store/rules.js +54 -0
  103. package/dist/store/rules.js.map +1 -1
  104. package/dist/store/specs-migration.d.ts +128 -0
  105. package/dist/store/specs-migration.d.ts.map +1 -0
  106. package/dist/store/specs-migration.js +481 -0
  107. package/dist/store/specs-migration.js.map +1 -0
  108. package/dist/store/specs-store.d.ts.map +1 -1
  109. package/dist/store/specs-store.js +14 -2
  110. package/dist/store/specs-store.js.map +1 -1
  111. package/dist/store/specs-writer.d.ts +130 -3
  112. package/dist/store/specs-writer.d.ts.map +1 -1
  113. package/dist/store/specs-writer.js +941 -102
  114. package/dist/store/specs-writer.js.map +1 -1
  115. package/dist/store/types.d.ts +6 -0
  116. package/dist/store/types.d.ts.map +1 -1
  117. package/dist/store/waivers-store.d.ts.map +1 -1
  118. package/dist/store/waivers-store.js +8 -1
  119. package/dist/store/waivers-store.js.map +1 -1
  120. package/dist/store/worktrees-writer.d.ts +28 -0
  121. package/dist/store/worktrees-writer.d.ts.map +1 -1
  122. package/dist/store/worktrees-writer.js +110 -12
  123. package/dist/store/worktrees-writer.js.map +1 -1
  124. package/package.json +5 -2
  125. package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
  126. package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
  127. package/templates/hook-packs/claude-code/agent-register.sh +1 -1
  128. package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
  129. package/templates/hook-packs/claude-code/audit.sh +1 -1
  130. package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
  131. package/templates/hook-packs/claude-code/classify_command.py +1 -1
  132. package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
  133. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
  134. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
  135. package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
  136. package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
  137. package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
  138. package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
  139. package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
  140. package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
  141. package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
  142. package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
  143. package/templates/hook-packs/claude-code/naming-check.sh +128 -0
  144. package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
  145. package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
  146. package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
  147. package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
  148. package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
  149. package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
  150. package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
  151. package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
  152. package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
  153. package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
  154. package/templates/hook-packs/claude-code/session-log.sh +1 -1
  155. package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
  156. package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
  157. package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
  158. package/templates/hook-packs/claude-code/worktree-write-guard.sh +1 -1
@@ -2,18 +2,44 @@
2
2
  // resolve-session — establish a SessionIdentity for the current shell call.
3
3
  //
4
4
  // This is the SOLE shell-side authority for "who is running this command?".
5
- // Source order is pinned by the rewrite plan:
5
+ // Source order:
6
6
  //
7
7
  // 1. CLAUDE_SESSION_ID env → platform = "claude-code"
8
- // 2. CAWS session capsule → on-disk `.caws/sessions/<id>.json` that
8
+ // (operator-set override; deliberate)
9
+ // 2. HOOK_SESSION_ID env → platform = "claude-code"
10
+ // (harness-stable id exported by the
11
+ // Claude Code hook envelope via
12
+ // lib/parse-input.sh; refused if the
13
+ // value is the literal "unknown")
14
+ // 2.5. Durable hook envelope → platform = "claude-code"
15
+ // (CAWS-SESSION-ID-DURABLE-HOOK-ENVELOPE-001;
16
+ // bridges HOOK_SESSION_ID across agent-Bash
17
+ // invocations where the env var doesn't
18
+ // propagate. Reads
19
+ // <repo_root>/tmp/<id>/.session-envelope.json
20
+ // files written by hook scripts. Filters by
21
+ // repo_root + 24h freshness on last_seen_at.
22
+ // Refuses with typed ambiguity diagnostic
23
+ // when two or more candidates match;
24
+ // NEVER newest-wins.)
25
+ // 3. CAWS session capsule → on-disk `.caws/sessions/<id>.json` that
9
26
  // names the current worktree root
10
- // 3. CURSOR_TRACE_ID env → platform = "cursor" (low-stability fallback)
11
- // 4. mint a new capsule (only when `allowMint: true` is passed by the
12
- // caller — read-only commands MUST NOT pass this flag)
27
+ // 4. CURSOR_TRACE_ID env → platform = "cursor" (low-stability fallback)
28
+ // 5. mint a new capsule (only when `allowMint: true` is passed by the
29
+ // caller — read-only commands MUST NOT pass this flag). The mint
30
+ // path DELETES any pre-existing capsule for the same worktree_root
31
+ // before writing the new one; cleanup failures are non-fatal
32
+ // warnings.
13
33
  //
14
34
  // Anything beyond this list — for example, inferring identity from
15
35
  // `agents.json` last-active — is NOT permitted. agents.json freshness is
16
36
  // display-only.
37
+ //
38
+ // The HOOK_SESSION_ID admission + mint-cleanup were added by
39
+ // CAWS-SESSION-ID-DRIFT-ENV-PRECEDENCE-001 to eliminate recurring
40
+ // "OWNED (foreign)" refusals after Claude Code session restarts that
41
+ // were forcing agents to normalize `caws claim --takeover`, defeating
42
+ // the audit contract.
17
43
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
44
  if (k2 === undefined) k2 = k;
19
45
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -50,6 +76,9 @@ var __importStar = (this && this.__importStar) || (function () {
50
76
  Object.defineProperty(exports, "__esModule", { value: true });
51
77
  exports.resolveSession = resolveSession;
52
78
  exports.describeSessionSource = describeSessionSource;
79
+ exports.resolveSessionCandidates = resolveSessionCandidates;
80
+ exports.admitsOwner = admitsOwner;
81
+ exports.describeCandidateTrace = describeCandidateTrace;
53
82
  const crypto = __importStar(require("node:crypto"));
54
83
  const fs = __importStar(require("node:fs"));
55
84
  const path = __importStar(require("node:path"));
@@ -57,6 +86,187 @@ const caws_kernel_1 = require("@paths.design/caws-kernel");
57
86
  const rules_1 = require("../rules");
58
87
  const atomic_write_1 = require("../../store/atomic-write");
59
88
  const SESSIONS_DIRNAME = 'sessions';
89
+ // CAWS-SESSION-ID-DURABLE-HOOK-ENVELOPE-001
90
+ const DURABLE_ENVELOPE_DIRNAME = 'tmp';
91
+ const DURABLE_ENVELOPE_FILENAME = '.session-envelope.json';
92
+ const DURABLE_ENVELOPE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
93
+ // CAWS-WORKTREE-OWNERSHIP-HARNESS-ID-001
94
+ // Per-repo caller-session pointer: `<repoRoot>/tmp/.caller-session.json`.
95
+ // Written/refreshed by the hook (parse-input.sh) from the authoritative
96
+ // hook-payload session_id. Consumed ONLY to disambiguate the
97
+ // >=2-fresh-envelope case in agent-Bash where HOOK_SESSION_ID is absent.
98
+ // Evidence, not authority: it can only narrow an ambiguous candidate set
99
+ // to the caller's own envelope; it never widens authority, never relaxes
100
+ // the foreign-claim refusal, and is NEVER a newest-wins fallback.
101
+ const CALLER_SESSION_POINTER_FILENAME = '.caller-session.json';
102
+ function isCallerSessionPointerShape(v) {
103
+ if (typeof v !== 'object' || v === null)
104
+ return false;
105
+ const o = v;
106
+ return (typeof o.session_id === 'string' &&
107
+ o.session_id.length > 0 &&
108
+ typeof o.last_seen_at === 'string');
109
+ }
110
+ /**
111
+ * Read the caller-session pointer at `<tmpDir>/.caller-session.json` and
112
+ * return the named session_id IFF the pointer exists, parses, is
113
+ * repo-matched, and is fresh (same freshness window as envelopes).
114
+ * Returns null on any miss — absent, unreadable, malformed, repo
115
+ * mismatch, or stale. Total and non-throwing: a missing/bad pointer
116
+ * MUST degrade to the existing ambiguity refusal, never to a guess.
117
+ */
118
+ function readCallerSessionPointer(args) {
119
+ const pointerPath = path.join(args.tmpDir, CALLER_SESSION_POINTER_FILENAME);
120
+ let raw;
121
+ try {
122
+ raw = fs.readFileSync(pointerPath, 'utf8');
123
+ }
124
+ catch {
125
+ return null; // absent or unreadable
126
+ }
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(raw);
130
+ }
131
+ catch {
132
+ return null; // malformed
133
+ }
134
+ if (!isCallerSessionPointerShape(parsed))
135
+ return null;
136
+ // Repo-root filter (realpath both sides; tolerate a missing field by
137
+ // skipping the filter only when the pointer omits repo_root — but the
138
+ // shape guard requires session_id + last_seen_at, not repo_root, so a
139
+ // pointer without repo_root is treated as repo-agnostic and accepted).
140
+ if (typeof parsed.repo_root === 'string') {
141
+ let pointerRepoReal;
142
+ try {
143
+ pointerRepoReal = fs.realpathSync(parsed.repo_root);
144
+ }
145
+ catch {
146
+ pointerRepoReal = parsed.repo_root;
147
+ }
148
+ if (pointerRepoReal !== args.repoRootReal)
149
+ return null;
150
+ }
151
+ // Freshness filter on last_seen_at, same window as envelopes.
152
+ const lastSeenMs = Date.parse(parsed.last_seen_at);
153
+ if (!Number.isFinite(lastSeenMs) ||
154
+ lastSeenMs < args.nowMs - DURABLE_ENVELOPE_FRESHNESS_MS) {
155
+ return null; // stale
156
+ }
157
+ return parsed.session_id;
158
+ }
159
+ function isDurableEnvelopeShape(v) {
160
+ if (typeof v !== 'object' || v === null)
161
+ return false;
162
+ const o = v;
163
+ return (typeof o.session_id === 'string' &&
164
+ o.session_id.length > 0 &&
165
+ typeof o.repo_root === 'string' &&
166
+ typeof o.last_seen_at === 'string');
167
+ }
168
+ /**
169
+ * Scan `<repoRoot>/tmp/<id>/.session-envelope.json` for durable
170
+ * hook-session envelopes matching the current call's repo_root and
171
+ * within the freshness window.
172
+ *
173
+ * Returns:
174
+ * - { ok: true, candidates: [...] } where candidates is filtered
175
+ * and freshness-checked. Caller decides accept/refuse based on
176
+ * count.
177
+ * - Diagnostic warnings for any malformed envelopes encountered
178
+ * (non-fatal — the scan continues past malformed files).
179
+ *
180
+ * Per invariant 5: scan failures are non-fatal. A missing or
181
+ * unreadable tmp/ directory returns an empty candidate set, not an
182
+ * error. The durable-envelope path is operational cache; the
183
+ * resolver falls through cleanly when nothing matches.
184
+ *
185
+ * Per invariant 7: stale envelopes (last_seen_at > 24h ago) are
186
+ * silently skipped — never deleted from the read path. Cleanup is
187
+ * operator-driven via `rm -rf tmp/<id>/`.
188
+ */
189
+ function scanDurableEnvelopes(args) {
190
+ const warnings = [];
191
+ const candidates = [];
192
+ let entries;
193
+ try {
194
+ entries = fs.readdirSync(args.tmpDir);
195
+ }
196
+ catch {
197
+ // No tmp directory or unreadable — no candidates, no warnings.
198
+ // This is the common case in repos that have never run a hook.
199
+ return { candidates, warnings };
200
+ }
201
+ let repoRootReal;
202
+ try {
203
+ repoRootReal = fs.realpathSync(args.repoRoot);
204
+ }
205
+ catch {
206
+ repoRootReal = args.repoRoot;
207
+ }
208
+ const nowMs = args.now.getTime();
209
+ const freshnessFloorMs = nowMs - DURABLE_ENVELOPE_FRESHNESS_MS;
210
+ for (const name of entries) {
211
+ const envelopePath = path.join(args.tmpDir, name, DURABLE_ENVELOPE_FILENAME);
212
+ let stat;
213
+ try {
214
+ stat = fs.statSync(envelopePath);
215
+ }
216
+ catch {
217
+ // No envelope file in this tmp/<name>/ subdir (might be a
218
+ // session-log dir, an auto-reset-dispatch-test dir, or any
219
+ // other tmp content). Skip silently.
220
+ continue;
221
+ }
222
+ if (!stat.isFile())
223
+ continue;
224
+ let raw;
225
+ try {
226
+ raw = fs.readFileSync(envelopePath, 'utf8');
227
+ }
228
+ catch (e) {
229
+ warnings.push(diag(rules_1.SHELL_RULES.SESSION_DURABLE_ENVELOPE_MALFORMED, `durable envelope read failed: ${e.message}`, { envelopePath }));
230
+ continue;
231
+ }
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(raw);
235
+ }
236
+ catch (e) {
237
+ warnings.push(diag(rules_1.SHELL_RULES.SESSION_DURABLE_ENVELOPE_MALFORMED, `durable envelope JSON parse failed: ${e.message}`, { envelopePath }));
238
+ continue;
239
+ }
240
+ if (!isDurableEnvelopeShape(parsed)) {
241
+ warnings.push(diag(rules_1.SHELL_RULES.SESSION_DURABLE_ENVELOPE_MALFORMED, 'durable envelope missing required fields (session_id, repo_root, last_seen_at)', { envelopePath }));
242
+ continue;
243
+ }
244
+ // Repo-root filter: realpath both sides to defeat /tmp vs
245
+ // /private/tmp differences on macOS.
246
+ let envRepoRootReal;
247
+ try {
248
+ envRepoRootReal = fs.realpathSync(parsed.repo_root);
249
+ }
250
+ catch {
251
+ envRepoRootReal = parsed.repo_root;
252
+ }
253
+ if (envRepoRootReal !== repoRootReal) {
254
+ // Envelope belongs to a different repo (e.g., another project's
255
+ // session that happened to leave a tmp/<id>/ here). Skip — NOT
256
+ // a warning; this is normal multi-repo developer state.
257
+ continue;
258
+ }
259
+ // Freshness filter on last_seen_at (NOT created_at — long-lived
260
+ // active sessions stay fresh via per-hook refresh per invariant 2).
261
+ const lastSeenMs = Date.parse(parsed.last_seen_at);
262
+ if (!Number.isFinite(lastSeenMs) || lastSeenMs < freshnessFloorMs) {
263
+ // Stale. Skip silently — operator-driven cleanup, not auto-delete.
264
+ continue;
265
+ }
266
+ candidates.push({ envelope: parsed, envelopePath });
267
+ }
268
+ return { candidates, warnings };
269
+ }
60
270
  function diag(rule, message, data) {
61
271
  const base = {
62
272
  rule,
@@ -143,6 +353,86 @@ function isCapsuleShape(value) {
143
353
  function defaultMintIdSuffix() {
144
354
  return crypto.randomBytes(6).toString('hex');
145
355
  }
356
+ /**
357
+ * Delete any pre-existing capsule files in `sessionsDir` whose
358
+ * `worktree_root` (resolved through fs.realpathSync — same rule
359
+ * `readCapsule` uses) matches the given worktreeRoot.
360
+ *
361
+ * This is the substrate-level fix for the recurring session-id drift
362
+ * that has been forcing agents to normalize `caws claim --takeover`.
363
+ * Pre-fix: multiple capsules accumulate per worktree_root across
364
+ * Claude Code session restarts; the first one found by readdirSync
365
+ * wins, but which one wins is filesystem-order-dependent and
366
+ * effectively random. Post-fix: at most one capsule exists for any
367
+ * given worktree_root at any time.
368
+ *
369
+ * Failures are non-fatal — the new capsule is still written. The
370
+ * caller surfaces failures as a warning Diagnostic on the Result.
371
+ */
372
+ function cleanupSupersededCapsules(sessionsDir, worktreeRoot) {
373
+ const deleted = [];
374
+ const warnings = [];
375
+ let entries;
376
+ try {
377
+ entries = fs.readdirSync(sessionsDir);
378
+ }
379
+ catch {
380
+ // Directory doesn't exist yet → nothing to clean up.
381
+ return { deleted, warnings };
382
+ }
383
+ // Resolve real paths (matching readCapsule's logic so /tmp vs
384
+ // /private/tmp on macOS doesn't strand stale capsules).
385
+ let worktreeRealRoot;
386
+ try {
387
+ worktreeRealRoot = fs.realpathSync(worktreeRoot);
388
+ }
389
+ catch {
390
+ worktreeRealRoot = worktreeRoot;
391
+ }
392
+ for (const name of entries) {
393
+ if (!name.endsWith('.json'))
394
+ continue;
395
+ const capsulePath = path.join(sessionsDir, name);
396
+ let raw;
397
+ try {
398
+ raw = fs.readFileSync(capsulePath, 'utf8');
399
+ }
400
+ catch (e) {
401
+ warnings.push(diag(rules_1.SHELL_RULES.SESSION_CAPSULE_CLEANUP_FAILED, `Could not read capsule for cleanup: ${e.message}`, { capsulePath }));
402
+ continue;
403
+ }
404
+ let parsed;
405
+ try {
406
+ parsed = JSON.parse(raw);
407
+ }
408
+ catch {
409
+ // Unparseable JSON — leave it alone; we don't know if it
410
+ // matches our worktree_root and we shouldn't delete what we
411
+ // can't classify.
412
+ continue;
413
+ }
414
+ if (!isCapsuleShape(parsed))
415
+ continue;
416
+ let capsuleWorktreeReal;
417
+ try {
418
+ capsuleWorktreeReal = fs.realpathSync(parsed.worktree_root);
419
+ }
420
+ catch {
421
+ capsuleWorktreeReal = parsed.worktree_root;
422
+ }
423
+ if (capsuleWorktreeReal !== worktreeRealRoot)
424
+ continue;
425
+ // Match — delete.
426
+ try {
427
+ fs.unlinkSync(capsulePath);
428
+ deleted.push(capsulePath);
429
+ }
430
+ catch (e) {
431
+ warnings.push(diag(rules_1.SHELL_RULES.SESSION_CAPSULE_CLEANUP_FAILED, `Could not delete superseded capsule: ${e.message}`, { capsulePath }));
432
+ }
433
+ }
434
+ return { deleted, warnings };
435
+ }
146
436
  function mintCapsule(opts) {
147
437
  const now = (opts.now ?? (() => new Date()))();
148
438
  const suffix = (opts.mintIdSuffix ?? defaultMintIdSuffix)();
@@ -163,6 +453,10 @@ function mintCapsule(opts) {
163
453
  diag(rules_1.SHELL_RULES.SESSION_CAPSULE_WRITE_FAILED, `Failed to create sessions directory: ${e.message}`, { sessionsDir }),
164
454
  ]);
165
455
  }
456
+ // Cleanup BEFORE write — guarantees the per-worktree-root uniqueness
457
+ // invariant on success. Cleanup failures are recorded as warnings;
458
+ // the mint itself does not fail.
459
+ const cleanup = cleanupSupersededCapsules(sessionsDir, opts.worktreeRoot);
166
460
  const capsulePath = path.join(sessionsDir, `${sessionId}.json`);
167
461
  const writeResult = (0, atomic_write_1.writeFileAtomic)(capsulePath, JSON.stringify(capsule, null, 2) + '\n');
168
462
  if (!writeResult.ok) {
@@ -170,13 +464,17 @@ function mintCapsule(opts) {
170
464
  diag(rules_1.SHELL_RULES.SESSION_CAPSULE_WRITE_FAILED, `Failed to write capsule: ${writeResult.errors[0]?.message ?? 'unknown error'}`, { capsulePath }),
171
465
  ]);
172
466
  }
173
- return (0, caws_kernel_1.ok)({ capsule, capsulePath });
467
+ return (0, caws_kernel_1.ok)({
468
+ capsule,
469
+ capsulePath,
470
+ cleanupWarnings: cleanup.warnings,
471
+ });
174
472
  }
175
473
  function resolveSession(opts) {
176
474
  const env = opts.env ?? process.env;
177
475
  const platform = opts.platform ?? process.platform;
178
476
  const allowMint = opts.allowMint === true;
179
- // 1. CLAUDE_SESSION_ID env (authority source #1)
477
+ // 1. CLAUDE_SESSION_ID env (authority source #1 — operator override)
180
478
  const claudeId = env['CLAUDE_SESSION_ID'];
181
479
  if (typeof claudeId === 'string' && claudeId.length > 0) {
182
480
  return (0, caws_kernel_1.ok)({
@@ -184,7 +482,129 @@ function resolveSession(opts) {
184
482
  source: 'claude_env',
185
483
  });
186
484
  }
187
- // 2. Capsule on disk (authority source #2)
485
+ // 1.5. CLAUDE_CODE_SESSION_ID env (authority source #1.5 — the harness's
486
+ // own per-session UUID, exported by Claude Code into EVERY tool
487
+ // subprocess, including the agent's Bash tool. Unlike HOOK_SESSION_ID
488
+ // (set only inside the hook's own shell, so it does NOT propagate to
489
+ // an agent-issued `caws` call), CLAUDE_CODE_SESSION_ID survives the
490
+ // tool boundary. Admitting it here resolves the agent-Bash write path
491
+ // deterministically to the true caller, so concurrent sessions no
492
+ // longer fall through to the racy tmp/.caller-session.json pointer
493
+ // (the last-writer-wins singleton that caused worktree-ownership
494
+ // misattribution). CAWS-SESSION-ID-AGENT-BASH-PROPAGATION-001.
495
+ //
496
+ // Refuse the literal 'unknown' and empty string for the same reason
497
+ // tier-2 does: never alias a broken context into a shared identity.
498
+ // Stays below CLAUDE_SESSION_ID so the operator override still wins.
499
+ const claudeCodeId = env['CLAUDE_CODE_SESSION_ID'];
500
+ if (typeof claudeCodeId === 'string' &&
501
+ claudeCodeId.length > 0 &&
502
+ claudeCodeId !== 'unknown') {
503
+ return (0, caws_kernel_1.ok)({
504
+ identity: { session_id: claudeCodeId, platform: 'claude-code' },
505
+ source: 'claude_code_env',
506
+ });
507
+ }
508
+ // 2. HOOK_SESSION_ID env (authority source #2 — harness-stable id
509
+ // exported by the Claude Code hook envelope via lib/parse-input.sh).
510
+ // Refuse the literal 'unknown' (parse-input.sh's fallback when the
511
+ // hook payload lacked a session_id); admitting it would alias every
512
+ // broken-context invocation into one shared capsule. Empty string
513
+ // is also refused.
514
+ const hookId = env['HOOK_SESSION_ID'];
515
+ if (typeof hookId === 'string' && hookId.length > 0 && hookId !== 'unknown') {
516
+ return (0, caws_kernel_1.ok)({
517
+ identity: { session_id: hookId, platform: 'claude-code' },
518
+ source: 'hook_env',
519
+ });
520
+ }
521
+ // 2.5. Durable hook envelope on disk (authority source #2.5)
522
+ // CAWS-SESSION-ID-DURABLE-HOOK-ENVELOPE-001: bridges
523
+ // HOOK_SESSION_ID across agent-Bash invocations where the env
524
+ // var doesn't propagate. The hook script writes/refreshes
525
+ // `<repo_root>/tmp/<id>/.session-envelope.json` on every fire;
526
+ // the resolver scans them filtered by repo_root + freshness.
527
+ //
528
+ // Authority discipline:
529
+ // - Repo-root filter is mandatory (no blind tmp/* scan).
530
+ // - Stale envelopes (>24h on last_seen_at) skipped silently.
531
+ // - Malformed envelopes skipped with non-fatal warning.
532
+ // - Two or more fresh matches → REFUSE with typed ambiguity
533
+ // diagnostic. NEVER newest-wins.
534
+ // - Zero matches → fall through to capsule (priority 3).
535
+ //
536
+ // Derive repoRoot from cawsDir (cawsDir is `<repoRoot>/.caws`).
537
+ const repoRoot = path.dirname(opts.cawsDir);
538
+ const tmpDir = path.join(repoRoot, DURABLE_ENVELOPE_DIRNAME);
539
+ const envScan = scanDurableEnvelopes({
540
+ repoRoot,
541
+ tmpDir,
542
+ now: opts.now ? opts.now() : new Date(),
543
+ });
544
+ if (envScan.candidates.length >= 2) {
545
+ // CAWS-WORKTREE-OWNERSHIP-HARNESS-ID-001: before refusing, consult the
546
+ // governed caller-session pointer. In agent-Bash HOOK_SESSION_ID is
547
+ // absent (source 2 skipped), so this pointer is the only caller-
548
+ // identity signal available to disambiguate. If it positively names
549
+ // exactly one of the fresh candidates, select that candidate — the
550
+ // caller's own envelope. This is an explicit identity match, NOT a
551
+ // recency heuristic; "NEVER newest-wins" is preserved. Absent / stale /
552
+ // malformed / non-matching pointer → fall through to the refusal below.
553
+ let repoRootReal;
554
+ try {
555
+ repoRootReal = fs.realpathSync(repoRoot);
556
+ }
557
+ catch {
558
+ repoRootReal = repoRoot;
559
+ }
560
+ const callerId = readCallerSessionPointer({
561
+ repoRootReal,
562
+ tmpDir,
563
+ nowMs: (opts.now ? opts.now() : new Date()).getTime(),
564
+ });
565
+ if (callerId !== null) {
566
+ const matches = envScan.candidates.filter((c) => c.envelope.session_id === callerId);
567
+ if (matches.length === 1) {
568
+ const mine = matches[0];
569
+ return (0, caws_kernel_1.ok)({
570
+ identity: {
571
+ session_id: mine.envelope.session_id,
572
+ platform: 'claude-code',
573
+ },
574
+ source: 'durable_hook_envelope',
575
+ envelopePath: mine.envelopePath,
576
+ }, envScan.warnings.length > 0 ? envScan.warnings : undefined);
577
+ }
578
+ }
579
+ const ids = envScan.candidates.map((c) => c.envelope.session_id);
580
+ const paths = envScan.candidates.map((c) => c.envelopePath);
581
+ return (0, caws_kernel_1.err)([
582
+ diag(rules_1.SHELL_RULES.SESSION_DURABLE_ENVELOPE_AMBIGUOUS, `Multiple fresh durable hook envelopes match repo_root ${repoRoot}. The resolver cannot pick a winner. Disambiguate by setting CLAUDE_SESSION_ID, by routing through a hook context that sets HOOK_SESSION_ID, or by removing stale tmp/<id>/ directories for sessions that have ended.`, {
583
+ repoRoot,
584
+ candidateCount: envScan.candidates.length,
585
+ candidateSessionIds: ids,
586
+ candidateEnvelopePaths: paths,
587
+ }),
588
+ ...envScan.warnings,
589
+ ]);
590
+ }
591
+ if (envScan.candidates.length === 1) {
592
+ const sole = envScan.candidates[0];
593
+ return (0, caws_kernel_1.ok)({
594
+ identity: {
595
+ session_id: sole.envelope.session_id,
596
+ platform: 'claude-code',
597
+ },
598
+ source: 'durable_hook_envelope',
599
+ envelopePath: sole.envelopePath,
600
+ }, envScan.warnings.length > 0 ? envScan.warnings : undefined);
601
+ }
602
+ // envScan.candidates.length === 0: fall through to capsule.
603
+ // Warnings (if any malformed envelopes were encountered) are dropped
604
+ // here — the resolver returns ok() from the capsule branch and we
605
+ // don't have a clean way to thread warnings through. Operators see
606
+ // these via direct envelope-shape inspection.
607
+ // 3. Capsule on disk (authority source #3)
188
608
  const cap = readCapsule(opts.cawsDir, opts.worktreeRoot);
189
609
  if (cap !== null) {
190
610
  return (0, caws_kernel_1.ok)({
@@ -196,7 +616,7 @@ function resolveSession(opts) {
196
616
  capsulePath: cap.capsulePath,
197
617
  });
198
618
  }
199
- // 3. CURSOR_TRACE_ID env (low-stability fallback)
619
+ // 4. CURSOR_TRACE_ID env (low-stability fallback)
200
620
  const cursorId = env['CURSOR_TRACE_ID'];
201
621
  if (typeof cursorId === 'string' && cursorId.length > 0) {
202
622
  return (0, caws_kernel_1.ok)({
@@ -204,7 +624,7 @@ function resolveSession(opts) {
204
624
  source: 'cursor_env',
205
625
  });
206
626
  }
207
- // 4. Mint a capsule — only when caller has opted in.
627
+ // 5. Mint a capsule — only when caller has opted in.
208
628
  if (!allowMint) {
209
629
  return (0, caws_kernel_1.err)([
210
630
  diag(rules_1.SHELL_RULES.SESSION_NO_STABLE_IDENTITY, 'No stable session identity could be resolved. Set CLAUDE_SESSION_ID or run a write-class command to mint a capsule.', { platform, cawsDir: opts.cawsDir, worktreeRoot: opts.worktreeRoot }),
@@ -213,6 +633,10 @@ function resolveSession(opts) {
213
633
  const minted = mintCapsule(opts);
214
634
  if (!minted.ok)
215
635
  return minted;
636
+ // Thread cleanup warnings (if any) through the Result so observers
637
+ // can see when superseded-capsule deletion failed without the mint
638
+ // itself failing.
639
+ const cleanupWarnings = minted.value.cleanupWarnings;
216
640
  return (0, caws_kernel_1.ok)({
217
641
  identity: {
218
642
  session_id: minted.value.capsule.session_id,
@@ -220,7 +644,7 @@ function resolveSession(opts) {
220
644
  },
221
645
  source: 'minted',
222
646
  capsulePath: minted.value.capsulePath,
223
- });
647
+ }, cleanupWarnings.length > 0 ? cleanupWarnings : undefined);
224
648
  }
225
649
  // Re-export for shell consumers that want a single info-level finding to
226
650
  // render alongside resolved-from-capsule results.
@@ -228,6 +652,12 @@ function describeSessionSource(s) {
228
652
  switch (s.source) {
229
653
  case 'claude_env':
230
654
  return infoDiag(rules_1.SHELL_RULES.SESSION_RESOLVED_FROM_CLAUDE_ENV, `Session identity from CLAUDE_SESSION_ID env: ${s.identity.session_id}`);
655
+ case 'claude_code_env':
656
+ return infoDiag(rules_1.SHELL_RULES.SESSION_RESOLVED_FROM_CLAUDE_CODE_ENV, `Session identity from CLAUDE_CODE_SESSION_ID env (Claude Code harness, survives the tool boundary): ${s.identity.session_id}`);
657
+ case 'hook_env':
658
+ return infoDiag(rules_1.SHELL_RULES.SESSION_RESOLVED_FROM_HOOK_ENV, `Session identity from HOOK_SESSION_ID env (Claude Code hook envelope): ${s.identity.session_id}`);
659
+ case 'durable_hook_envelope':
660
+ return infoDiag(rules_1.SHELL_RULES.SESSION_RESOLVED_FROM_DURABLE_ENVELOPE, `Session identity from durable hook envelope: ${s.identity.session_id}`, s.envelopePath !== undefined ? { envelopePath: s.envelopePath } : undefined);
231
661
  case 'capsule':
232
662
  return infoDiag(rules_1.SHELL_RULES.SESSION_RESOLVED_FROM_CAPSULE, `Session identity from capsule: ${s.identity.session_id}`, s.capsulePath !== undefined ? { capsulePath: s.capsulePath } : undefined);
233
663
  case 'cursor_env':
@@ -236,4 +666,380 @@ function describeSessionSource(s) {
236
666
  return infoDiag(rules_1.SHELL_RULES.SESSION_CAPSULE_MINTED, `Minted new session capsule: ${s.identity.session_id}`, s.capsulePath !== undefined ? { capsulePath: s.capsulePath } : undefined);
237
667
  }
238
668
  }
669
+ // ─── resolveSessionCandidates ───────────────────────────────────────────
670
+ //
671
+ // Multi-source admission helper. Returns ZERO or more SessionIdentity
672
+ // candidates plus a diagnostic trace. NEVER mints. Designed for the
673
+ // ownership-comparison surfaces (worktree destroy, merge) where the
674
+ // question is "is the invoking process speaking for the registered
675
+ // owner?" rather than "what identity should we stamp on a new record?".
676
+ //
677
+ // Source order MIRRORS resolveSession (CLAUDE_SESSION_ID,
678
+ // HOOK_SESSION_ID, capsules, CURSOR_TRACE_ID) but is EXHAUSTIVE — every
679
+ // source is consulted, not first-match. Capsules contribute every
680
+ // well-formed entry under .caws/sessions/*.json regardless of
681
+ // worktree_root, eliminating the cwd-sensitivity that caused
682
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001.
683
+ //
684
+ // Why no mint: ownership comparison should never invent an identity
685
+ // that didn't exist before the comparison started. Minting on a failed
686
+ // match would (a) leave a stale capsule on disk after a refused
687
+ // comparison and (b) make the comparison's "no match" outcome
688
+ // non-reproducible because the mint randomized state. The right
689
+ // behavior on no-candidates-match is the refusal that the destroy/merge
690
+ // command already issues, surfaced with the trace so the user sees
691
+ // which sources were consulted.
692
+ function readAllCapsules(cawsDir) {
693
+ const sessionsDir = path.join(cawsDir, SESSIONS_DIRNAME);
694
+ let entries;
695
+ try {
696
+ entries = fs.readdirSync(sessionsDir);
697
+ }
698
+ catch {
699
+ return {
700
+ candidates: [],
701
+ trace: {
702
+ source: 'capsule',
703
+ outcome: 'absent',
704
+ reason: 'sessions directory does not exist',
705
+ count: 0,
706
+ },
707
+ };
708
+ }
709
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001 L7: pin iteration
710
+ // order so candidate ordering and diagnostic rendering are stable
711
+ // across runs (readdirSync returns FS-order, which is not portable).
712
+ entries.sort();
713
+ const candidates = [];
714
+ let rejectedCount = 0;
715
+ let raceCount = 0;
716
+ const rejectionReasons = [];
717
+ const raceReasons = [];
718
+ for (const name of entries) {
719
+ if (!name.endsWith('.json'))
720
+ continue;
721
+ const capsulePath = path.join(sessionsDir, name);
722
+ let raw;
723
+ try {
724
+ raw = fs.readFileSync(capsulePath, 'utf8');
725
+ }
726
+ catch (e) {
727
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001 L1: ENOENT
728
+ // between readdir and readFile means a sibling process (e.g.,
729
+ // another mint's cleanupSupersededCapsules) removed the file.
730
+ // Surface as 'race' so operators don't debug it as a content
731
+ // problem. Any other error (EACCES, EIO) is a real read failure.
732
+ const err = e;
733
+ if (err.code === 'ENOENT') {
734
+ raceCount++;
735
+ raceReasons.push(`concurrent-removal: ${name}`);
736
+ }
737
+ else {
738
+ rejectedCount++;
739
+ rejectionReasons.push(`unreadable: ${name}: ${err.message}`);
740
+ }
741
+ continue;
742
+ }
743
+ let parsed;
744
+ try {
745
+ parsed = JSON.parse(raw);
746
+ }
747
+ catch {
748
+ rejectedCount++;
749
+ rejectionReasons.push(`unparseable: ${name}`);
750
+ continue;
751
+ }
752
+ if (!isCapsuleShape(parsed)) {
753
+ rejectedCount++;
754
+ rejectionReasons.push(`malformed: ${name}`);
755
+ continue;
756
+ }
757
+ candidates.push({
758
+ identity: {
759
+ session_id: parsed.session_id,
760
+ platform: parsed.platform,
761
+ },
762
+ source: 'capsule',
763
+ capsulePath,
764
+ });
765
+ }
766
+ if (candidates.length > 0) {
767
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001 L2: record the
768
+ // admitted session_ids so describeCandidateTrace can render them
769
+ // in refusal diagnostics. The trace's job is to let an operator
770
+ // see EXACTLY which identities were considered, not just a count.
771
+ return {
772
+ candidates,
773
+ trace: {
774
+ source: 'capsule',
775
+ outcome: 'admitted',
776
+ count: candidates.length,
777
+ admittedIds: candidates.map((c) => c.identity.session_id),
778
+ },
779
+ };
780
+ }
781
+ if (rejectedCount > 0) {
782
+ return {
783
+ candidates: [],
784
+ trace: {
785
+ source: 'capsule',
786
+ outcome: 'rejected',
787
+ reason: rejectionReasons.join('; '),
788
+ count: 0,
789
+ },
790
+ };
791
+ }
792
+ if (raceCount > 0) {
793
+ return {
794
+ candidates: [],
795
+ trace: {
796
+ source: 'capsule',
797
+ outcome: 'race',
798
+ reason: raceReasons.join('; '),
799
+ count: 0,
800
+ },
801
+ };
802
+ }
803
+ return {
804
+ candidates: [],
805
+ trace: {
806
+ source: 'capsule',
807
+ outcome: 'absent',
808
+ reason: 'no capsule files in sessions directory',
809
+ count: 0,
810
+ },
811
+ };
812
+ }
813
+ /**
814
+ * Resolve every session identity the current process can plausibly
815
+ * speak for. See SessionCandidates docs in ./types.ts for the contract.
816
+ *
817
+ * Pure function over (env, cawsDir, on-disk capsule files). No mutation,
818
+ * no minting, no side effects.
819
+ */
820
+ function resolveSessionCandidates(opts) {
821
+ const env = opts.env ?? process.env;
822
+ const candidates = [];
823
+ const trace = [];
824
+ // 1. CLAUDE_SESSION_ID env
825
+ const claudeId = env['CLAUDE_SESSION_ID'];
826
+ if (typeof claudeId === 'string' && claudeId.length > 0) {
827
+ candidates.push({
828
+ identity: { session_id: claudeId, platform: 'claude-code' },
829
+ source: 'claude_env',
830
+ });
831
+ trace.push({
832
+ source: 'claude_env',
833
+ outcome: 'admitted',
834
+ count: 1,
835
+ admittedIds: [claudeId],
836
+ });
837
+ }
838
+ else {
839
+ trace.push({
840
+ source: 'claude_env',
841
+ outcome: 'absent',
842
+ reason: 'CLAUDE_SESSION_ID not set',
843
+ });
844
+ }
845
+ // 1.5. CLAUDE_CODE_SESSION_ID env (refuse literal 'unknown' and empty).
846
+ // CAWS-SESSION-ID-AGENT-BASH-PROPAGATION-001: the harness UUID that
847
+ // survives the tool boundary into agent-Bash. Admitted as a candidate
848
+ // so ownership comparison can match a worktree owner stamped from this
849
+ // same source — exact session_id equality, never widens authority.
850
+ const claudeCodeId = env['CLAUDE_CODE_SESSION_ID'];
851
+ if (typeof claudeCodeId === 'string' &&
852
+ claudeCodeId.length > 0 &&
853
+ claudeCodeId !== 'unknown') {
854
+ candidates.push({
855
+ identity: { session_id: claudeCodeId, platform: 'claude-code' },
856
+ source: 'claude_code_env',
857
+ });
858
+ trace.push({
859
+ source: 'claude_code_env',
860
+ outcome: 'admitted',
861
+ count: 1,
862
+ admittedIds: [claudeCodeId],
863
+ });
864
+ }
865
+ else if (claudeCodeId === 'unknown') {
866
+ trace.push({
867
+ source: 'claude_code_env',
868
+ outcome: 'rejected',
869
+ reason: 'CLAUDE_CODE_SESSION_ID is literal "unknown"',
870
+ });
871
+ }
872
+ else {
873
+ trace.push({
874
+ source: 'claude_code_env',
875
+ outcome: 'absent',
876
+ reason: 'CLAUDE_CODE_SESSION_ID not set',
877
+ });
878
+ }
879
+ // 2. HOOK_SESSION_ID env (refuse literal 'unknown' and empty)
880
+ const hookId = env['HOOK_SESSION_ID'];
881
+ if (typeof hookId === 'string' && hookId.length > 0 && hookId !== 'unknown') {
882
+ candidates.push({
883
+ identity: { session_id: hookId, platform: 'claude-code' },
884
+ source: 'hook_env',
885
+ });
886
+ trace.push({
887
+ source: 'hook_env',
888
+ outcome: 'admitted',
889
+ count: 1,
890
+ admittedIds: [hookId],
891
+ });
892
+ }
893
+ else if (hookId === 'unknown') {
894
+ trace.push({
895
+ source: 'hook_env',
896
+ outcome: 'rejected',
897
+ reason: 'HOOK_SESSION_ID is literal "unknown" (parse-input.sh fallback)',
898
+ });
899
+ }
900
+ else {
901
+ trace.push({
902
+ source: 'hook_env',
903
+ outcome: 'absent',
904
+ reason: 'HOOK_SESSION_ID not set',
905
+ });
906
+ }
907
+ // 2.5. Durable hook envelopes on disk
908
+ // CAWS-WORKTREE-DESTROY-GHOST-ENTRY-OWNER-UNRESOLVABLE-001.
909
+ //
910
+ // This source MIRRORS resolveSession's step 2.5 — the same
911
+ // scanDurableEnvelopes() over `<repoRoot>/tmp/<id>/.session-envelope.json`,
912
+ // repo-root-filtered and freshness-checked — but with one
913
+ // deliberate divergence in the >=2 case.
914
+ //
915
+ // resolveSession() REFUSES on >=2 fresh envelopes because it must
916
+ // pick exactly ONE identity to STAMP onto a new record; guessing
917
+ // would be newest-wins, which the spec forbids.
918
+ //
919
+ // resolveSessionCandidates() ADMITS ALL fresh envelopes. This is an
920
+ // ownership-COMPARISON surface — the question is "can the invoking
921
+ // process speak for the registered owner?", answered downstream by
922
+ // admitsOwner()'s exact session_id equality. Admitting every fresh
923
+ // envelope cannot widen authority: a foreign owner whose envelope is
924
+ // not on disk still has no matching candidate, so the destroy/merge
925
+ // refusal still fires (A4). What it DOES fix is the ghost-entry case
926
+ // where the registered owner IS one of the fresh envelopes (the
927
+ // caller's own claude-code UUID session) but, in agent-Bash,
928
+ // HOOK_SESSION_ID is absent and no capsule carries that UUID — so the
929
+ // pre-fix candidate set never saw the owner, and destroy refused a
930
+ // worktree the caller legitimately owns. `caws status` already
931
+ // resolved that same UUID as "self" via this exact envelope source;
932
+ // this aligns the comparison surface with the display surface.
933
+ const repoRoot = path.dirname(opts.cawsDir);
934
+ const tmpDir = path.join(repoRoot, DURABLE_ENVELOPE_DIRNAME);
935
+ const envScan = scanDurableEnvelopes({
936
+ repoRoot,
937
+ tmpDir,
938
+ now: opts.now ? opts.now() : new Date(),
939
+ });
940
+ if (envScan.candidates.length > 0) {
941
+ for (const c of envScan.candidates) {
942
+ candidates.push({
943
+ identity: {
944
+ session_id: c.envelope.session_id,
945
+ platform: 'claude-code',
946
+ },
947
+ source: 'durable_hook_envelope',
948
+ envelopePath: c.envelopePath,
949
+ });
950
+ }
951
+ trace.push({
952
+ source: 'durable_hook_envelope',
953
+ outcome: 'admitted',
954
+ count: envScan.candidates.length,
955
+ admittedIds: envScan.candidates.map((c) => c.envelope.session_id),
956
+ });
957
+ }
958
+ else {
959
+ trace.push({
960
+ source: 'durable_hook_envelope',
961
+ outcome: 'absent',
962
+ reason: `no fresh durable hook envelope under ${tmpDir} matched repo_root ${repoRoot}`,
963
+ });
964
+ }
965
+ // 3. ALL capsules on disk (NOT cwd-keyed; that is the key distinction
966
+ // from resolveSession's step-3 behavior)
967
+ const capsuleResult = readAllCapsules(opts.cawsDir);
968
+ candidates.push(...capsuleResult.candidates);
969
+ trace.push(capsuleResult.trace);
970
+ // 4. CURSOR_TRACE_ID env (low-stability fallback)
971
+ const cursorId = env['CURSOR_TRACE_ID'];
972
+ if (typeof cursorId === 'string' && cursorId.length > 0) {
973
+ candidates.push({
974
+ identity: { session_id: cursorId, platform: 'cursor' },
975
+ source: 'cursor_env',
976
+ });
977
+ trace.push({
978
+ source: 'cursor_env',
979
+ outcome: 'admitted',
980
+ count: 1,
981
+ admittedIds: [cursorId],
982
+ });
983
+ }
984
+ else {
985
+ trace.push({
986
+ source: 'cursor_env',
987
+ outcome: 'absent',
988
+ reason: 'CURSOR_TRACE_ID not set',
989
+ });
990
+ }
991
+ return { candidates, trace };
992
+ }
993
+ /**
994
+ * Ownership-admission predicate: does any candidate in the set match
995
+ * the given registered owner's session_id?
996
+ *
997
+ * Match semantics are session_id equality only. Platform is NOT
998
+ * compared — a candidate sourced from CLAUDE_SESSION_ID env with
999
+ * platform 'claude-code' is admissible against an owner record that
1000
+ * happens to lack platform metadata; this is the same equality rule
1001
+ * the destroyWorktree comparison uses today
1002
+ * (entry.owner.session_id !== input.session.session_id at
1003
+ * worktrees-writer.ts:772).
1004
+ */
1005
+ function admitsOwner(candidates, ownerSessionId) {
1006
+ for (const c of candidates.candidates) {
1007
+ if (c.identity.session_id === ownerSessionId)
1008
+ return c;
1009
+ }
1010
+ return null;
1011
+ }
1012
+ /**
1013
+ * Render the trace as a multi-line human-readable diagnostic. Used by
1014
+ * destroy/merge when admission fails — the user needs to see EXACTLY
1015
+ * which sources were consulted and why none matched, to satisfy the
1016
+ * spec's non_functional.reliability invariant against silent fallbacks.
1017
+ */
1018
+ function describeCandidateTrace(candidates) {
1019
+ const lines = [];
1020
+ for (const entry of candidates.trace) {
1021
+ const base = ` - ${entry.source}: ${entry.outcome}`;
1022
+ if (entry.outcome === 'admitted') {
1023
+ const count = entry.count ?? 0;
1024
+ lines.push(`${base} (count=${count})`);
1025
+ // CAWS-WORKTREE-DESTROY-SESSION-RESOLUTION-001 L2: render the
1026
+ // admitted session_ids so the operator can compare them against
1027
+ // the registered owner. IDs are truncated to the first 16 chars
1028
+ // (display-friendly; collision-resistant given the 12-hex-char
1029
+ // entropy of caws-${hex6} mint format). Full IDs remain on
1030
+ // entry.admittedIds for programmatic inspection.
1031
+ if (entry.admittedIds !== undefined && entry.admittedIds.length > 0) {
1032
+ for (const id of entry.admittedIds) {
1033
+ const display = id.length > 16 ? `${id.slice(0, 16)}…` : id;
1034
+ lines.push(` candidate: ${display}`);
1035
+ }
1036
+ }
1037
+ }
1038
+ else {
1039
+ const detail = entry.reason !== undefined ? ` — ${entry.reason}` : '';
1040
+ lines.push(base + detail);
1041
+ }
1042
+ }
1043
+ return lines.join('\n');
1044
+ }
239
1045
  //# sourceMappingURL=resolve-session.js.map