@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.
- package/dist/index.js +55 -58
- package/dist/init/hook-packs/manifest-claude-code.d.ts +1 -1
- package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -1
- package/dist/init/hook-packs/manifest-claude-code.js +260 -2
- package/dist/init/hook-packs/manifest-claude-code.js.map +1 -1
- package/dist/shell/binding/resolve-binding.d.ts.map +1 -1
- package/dist/shell/binding/resolve-binding.js +105 -1
- package/dist/shell/binding/resolve-binding.js.map +1 -1
- package/dist/shell/binding/types.d.ts +47 -3
- package/dist/shell/binding/types.d.ts.map +1 -1
- package/dist/shell/command-metadata.d.ts +93 -0
- package/dist/shell/command-metadata.d.ts.map +1 -0
- package/dist/shell/command-metadata.js +687 -0
- package/dist/shell/command-metadata.js.map +1 -0
- package/dist/shell/commands/agents.d.ts +1 -2
- package/dist/shell/commands/agents.d.ts.map +1 -1
- package/dist/shell/commands/claim.d.ts +16 -0
- package/dist/shell/commands/claim.d.ts.map +1 -1
- package/dist/shell/commands/claim.js +85 -26
- package/dist/shell/commands/claim.js.map +1 -1
- package/dist/shell/commands/events.d.ts +106 -0
- package/dist/shell/commands/events.d.ts.map +1 -0
- package/dist/shell/commands/events.js +510 -0
- package/dist/shell/commands/events.js.map +1 -0
- package/dist/shell/commands/gates.d.ts +2 -2
- package/dist/shell/commands/gates.d.ts.map +1 -1
- package/dist/shell/commands/gates.js +106 -25
- package/dist/shell/commands/gates.js.map +1 -1
- package/dist/shell/commands/init.d.ts.map +1 -1
- package/dist/shell/commands/init.js +26 -0
- package/dist/shell/commands/init.js.map +1 -1
- package/dist/shell/commands/prepush.d.ts +26 -0
- package/dist/shell/commands/prepush.d.ts.map +1 -0
- package/dist/shell/commands/prepush.js +373 -0
- package/dist/shell/commands/prepush.js.map +1 -0
- package/dist/shell/commands/scope.d.ts.map +1 -1
- package/dist/shell/commands/scope.js +31 -1
- package/dist/shell/commands/scope.js.map +1 -1
- package/dist/shell/commands/specs.d.ts +44 -3
- package/dist/shell/commands/specs.d.ts.map +1 -1
- package/dist/shell/commands/specs.js +411 -15
- package/dist/shell/commands/specs.js.map +1 -1
- package/dist/shell/commands/worktree.d.ts.map +1 -1
- package/dist/shell/commands/worktree.js +51 -1
- package/dist/shell/commands/worktree.js.map +1 -1
- package/dist/shell/gates/disposition.d.ts.map +1 -1
- package/dist/shell/gates/disposition.js +43 -2
- package/dist/shell/gates/disposition.js.map +1 -1
- package/dist/shell/index.d.ts +10 -4
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +22 -2
- package/dist/shell/index.js.map +1 -1
- package/dist/shell/legacy-command-map.js +832 -0
- package/dist/shell/push-range/classify-range.d.ts +99 -0
- package/dist/shell/push-range/classify-range.d.ts.map +1 -0
- package/dist/shell/push-range/classify-range.js +155 -0
- package/dist/shell/push-range/classify-range.js.map +1 -0
- package/dist/shell/push-range/scope-match.d.ts +13 -0
- package/dist/shell/push-range/scope-match.d.ts.map +1 -0
- package/dist/shell/push-range/scope-match.js +53 -0
- package/dist/shell/push-range/scope-match.js.map +1 -0
- package/dist/shell/register.d.ts.map +1 -1
- package/dist/shell/register.js +263 -228
- package/dist/shell/register.js.map +1 -1
- package/dist/shell/registered-command-groups.js +48 -0
- package/dist/shell/rules.d.ts +19 -0
- package/dist/shell/rules.d.ts.map +1 -1
- package/dist/shell/rules.js +27 -0
- package/dist/shell/rules.js.map +1 -1
- package/dist/shell/session/resolve-session.d.ts +29 -1
- package/dist/shell/session/resolve-session.d.ts.map +1 -1
- package/dist/shell/session/resolve-session.js +817 -11
- package/dist/shell/session/resolve-session.js.map +1 -1
- package/dist/shell/session/types.d.ts +127 -1
- package/dist/shell/session/types.d.ts.map +1 -1
- package/dist/shell/session/types.js +10 -4
- package/dist/shell/session/types.js.map +1 -1
- package/dist/store/doctor-snapshot.d.ts.map +1 -1
- package/dist/store/doctor-snapshot.js +26 -0
- package/dist/store/doctor-snapshot.js.map +1 -1
- package/dist/store/events-migration.d.ts +207 -0
- package/dist/store/events-migration.d.ts.map +1 -0
- package/dist/store/events-migration.js +358 -0
- package/dist/store/events-migration.js.map +1 -0
- package/dist/store/events-store.d.ts +47 -1
- package/dist/store/events-store.d.ts.map +1 -1
- package/dist/store/events-store.js +278 -0
- package/dist/store/events-store.js.map +1 -1
- package/dist/store/git-autocommit.d.ts +46 -0
- package/dist/store/git-autocommit.d.ts.map +1 -0
- package/dist/store/git-autocommit.js +198 -0
- package/dist/store/git-autocommit.js.map +1 -0
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +7 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/leases-store.d.ts.map +1 -1
- package/dist/store/leases-store.js +58 -0
- package/dist/store/leases-store.js.map +1 -1
- package/dist/store/rules.d.ts +53 -0
- package/dist/store/rules.d.ts.map +1 -1
- package/dist/store/rules.js +54 -0
- package/dist/store/rules.js.map +1 -1
- package/dist/store/specs-migration.d.ts +128 -0
- package/dist/store/specs-migration.d.ts.map +1 -0
- package/dist/store/specs-migration.js +481 -0
- package/dist/store/specs-migration.js.map +1 -0
- package/dist/store/specs-store.d.ts.map +1 -1
- package/dist/store/specs-store.js +14 -2
- package/dist/store/specs-store.js.map +1 -1
- package/dist/store/specs-writer.d.ts +130 -3
- package/dist/store/specs-writer.d.ts.map +1 -1
- package/dist/store/specs-writer.js +941 -102
- package/dist/store/specs-writer.js.map +1 -1
- package/dist/store/types.d.ts +6 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/waivers-store.d.ts.map +1 -1
- package/dist/store/waivers-store.js +8 -1
- package/dist/store/waivers-store.js.map +1 -1
- package/dist/store/worktrees-writer.d.ts +28 -0
- package/dist/store/worktrees-writer.d.ts.map +1 -1
- package/dist/store/worktrees-writer.js +110 -12
- package/dist/store/worktrees-writer.js.map +1 -1
- package/package.json +5 -2
- package/templates/hook-packs/claude-code/CLAUDE.md +7 -1
- package/templates/hook-packs/claude-code/agent-heartbeat.sh +1 -1
- package/templates/hook-packs/claude-code/agent-register.sh +1 -1
- package/templates/hook-packs/claude-code/agent-stop.sh +1 -1
- package/templates/hook-packs/claude-code/audit.sh +1 -1
- package/templates/hook-packs/claude-code/block-dangerous.sh +1 -1
- package/templates/hook-packs/claude-code/classify_command.py +1 -1
- package/templates/hook-packs/claude-code/cwd-guard.sh +30 -0
- package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +15 -4
- package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +10 -2
- package/templates/hook-packs/claude-code/dispatch/session_start.sh +1 -1
- package/templates/hook-packs/claude-code/dispatch/stop.sh +2 -2
- package/templates/hook-packs/claude-code/duplicate-export-check.sh +156 -0
- package/templates/hook-packs/claude-code/god-object-check.sh +102 -0
- package/templates/hook-packs/claude-code/guard-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/lib/parse-input.sh +115 -1
- package/templates/hook-packs/claude-code/lib/run-handlers.sh +1 -1
- package/templates/hook-packs/claude-code/loc-delta-check.sh +91 -0
- package/templates/hook-packs/claude-code/naming-check.sh +128 -0
- package/templates/hook-packs/claude-code/plan-transcript-finalize.sh +59 -0
- package/templates/hook-packs/claude-code/plan-transcript-snapshot.sh +86 -0
- package/templates/hook-packs/claude-code/protected-paths.sh +59 -0
- package/templates/hook-packs/claude-code/quiet-merge.sh +68 -0
- package/templates/hook-packs/claude-code/reset-danger-latch.sh +1 -1
- package/templates/hook-packs/claude-code/reset-strikes.sh +1 -1
- package/templates/hook-packs/claude-code/runtime-paths.sh +1 -1
- package/templates/hook-packs/claude-code/scan-secrets.sh +98 -0
- package/templates/hook-packs/claude-code/scope-guard.sh +47 -65
- package/templates/hook-packs/claude-code/session-caws-status.sh +1 -1
- package/templates/hook-packs/claude-code/session-log.sh +1 -1
- package/templates/hook-packs/claude-code/session_log_renderer.py +956 -0
- package/templates/hook-packs/claude-code/shortcut-language-check.sh +147 -0
- package/templates/hook-packs/claude-code/worktree-guard.sh +1 -1
- 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
|
|
5
|
+
// Source order:
|
|
6
6
|
//
|
|
7
7
|
// 1. CLAUDE_SESSION_ID env → platform = "claude-code"
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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)({
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|