wicked-crew 0.7.28 → 0.7.29

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 (96) hide show
  1. package/README.md +9 -1
  2. package/dist/api/chat-scope.d.ts +210 -0
  3. package/dist/api/chat-scope.d.ts.map +1 -0
  4. package/dist/api/chat-scope.js +724 -0
  5. package/dist/api/chat-scope.js.map +1 -0
  6. package/dist/api/diagnostics.d.ts +30 -2
  7. package/dist/api/diagnostics.d.ts.map +1 -1
  8. package/dist/api/diagnostics.js +73 -5
  9. package/dist/api/diagnostics.js.map +1 -1
  10. package/dist/api/governance-health.d.ts +163 -0
  11. package/dist/api/governance-health.d.ts.map +1 -0
  12. package/dist/api/governance-health.js +348 -0
  13. package/dist/api/governance-health.js.map +1 -0
  14. package/dist/api/open-path.d.ts.map +1 -1
  15. package/dist/api/open-path.js +2 -1
  16. package/dist/api/open-path.js.map +1 -1
  17. package/dist/api/post-hoc-deliver.d.ts +9 -5
  18. package/dist/api/post-hoc-deliver.d.ts.map +1 -1
  19. package/dist/api/post-hoc-deliver.js +21 -7
  20. package/dist/api/post-hoc-deliver.js.map +1 -1
  21. package/dist/api/routes.d.ts +48 -12
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +309 -48
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/seat-health.d.ts.map +1 -1
  26. package/dist/api/seat-health.js +25 -5
  27. package/dist/api/seat-health.js.map +1 -1
  28. package/dist/api/seat-signin.d.ts.map +1 -1
  29. package/dist/api/seat-signin.js +49 -23
  30. package/dist/api/seat-signin.js.map +1 -1
  31. package/dist/api/server.d.ts.map +1 -1
  32. package/dist/api/server.js +20 -0
  33. package/dist/api/server.js.map +1 -1
  34. package/dist/campaigns/routes.d.ts +4 -4
  35. package/dist/cli/governance.d.ts +131 -0
  36. package/dist/cli/governance.d.ts.map +1 -0
  37. package/dist/cli/governance.js +354 -0
  38. package/dist/cli/governance.js.map +1 -0
  39. package/dist/cli/index.js +69 -2
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/core/adapter.d.ts +89 -1
  42. package/dist/core/adapter.d.ts.map +1 -1
  43. package/dist/core/adapter.js +123 -7
  44. package/dist/core/adapter.js.map +1 -1
  45. package/dist/core/bridge-reaper.d.ts.map +1 -1
  46. package/dist/core/bridge-reaper.js +4 -1
  47. package/dist/core/bridge-reaper.js.map +1 -1
  48. package/dist/core/deliver-text.d.ts +184 -0
  49. package/dist/core/deliver-text.d.ts.map +1 -0
  50. package/dist/core/deliver-text.js +434 -0
  51. package/dist/core/deliver-text.js.map +1 -0
  52. package/dist/core/deliver-triage.d.ts +90 -0
  53. package/dist/core/deliver-triage.d.ts.map +1 -0
  54. package/dist/core/deliver-triage.js +106 -0
  55. package/dist/core/deliver-triage.js.map +1 -0
  56. package/dist/core/deliver.d.ts +103 -9
  57. package/dist/core/deliver.d.ts.map +1 -1
  58. package/dist/core/deliver.js +244 -46
  59. package/dist/core/deliver.js.map +1 -1
  60. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  61. package/dist/core/estate-mcp-client.js +7 -2
  62. package/dist/core/estate-mcp-client.js.map +1 -1
  63. package/dist/core/exec.d.ts.map +1 -1
  64. package/dist/core/exec.js +11 -1
  65. package/dist/core/exec.js.map +1 -1
  66. package/dist/core/governance-store.d.ts +197 -0
  67. package/dist/core/governance-store.d.ts.map +1 -0
  68. package/dist/core/governance-store.js +283 -0
  69. package/dist/core/governance-store.js.map +1 -0
  70. package/dist/core/repoPaths.d.ts +36 -5
  71. package/dist/core/repoPaths.d.ts.map +1 -1
  72. package/dist/core/repoPaths.js +46 -4
  73. package/dist/core/repoPaths.js.map +1 -1
  74. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  75. package/dist/interactive/bridge-pool.js +9 -4
  76. package/dist/interactive/bridge-pool.js.map +1 -1
  77. package/dist/projects/graph-paths.d.ts +4 -2
  78. package/dist/projects/graph-paths.d.ts.map +1 -1
  79. package/dist/projects/graph-paths.js +4 -2
  80. package/dist/projects/graph-paths.js.map +1 -1
  81. package/dist/projects/graph.d.ts +5 -4
  82. package/dist/projects/graph.d.ts.map +1 -1
  83. package/dist/projects/graph.js +22 -5
  84. package/dist/projects/graph.js.map +1 -1
  85. package/dist/projects/routes.d.ts.map +1 -1
  86. package/dist/projects/routes.js +18 -9
  87. package/dist/projects/routes.js.map +1 -1
  88. package/dist/skills/plugin-source.d.ts.map +1 -1
  89. package/dist/skills/plugin-source.js +3 -0
  90. package/dist/skills/plugin-source.js.map +1 -1
  91. package/dist/studio/assets/index-CeJTutxP.js +540 -0
  92. package/dist/studio/index.html +1 -1
  93. package/dist/studio/testid-inventory.json +212 -6
  94. package/endpoint-manifest.json +60 -12
  95. package/package.json +3 -3
  96. package/dist/studio/assets/index-CdmCMh2f.js +0 -539
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Chat scope (crew#502, acceptance finding F-067): WHAT a chat's seats can see, decided up front
3
+ * and stated — to the seats and to the caller.
4
+ *
5
+ * `POST /chats` used to set the seats' cwd only when `repoRef` was sent, and the engine fell back
6
+ * to `current_dir()` — the DAEMON's working directory. Studio's GroupChat never sent `repoRef`, so
7
+ * every chat explored wherever `wicked-crew serve` had been started from (a customer's `$HOME`),
8
+ * with no estate MCP, no repo list and no statement of the scope; "chat with all 9 repos" worked
9
+ * only because the acceptance rig started the daemon from the parent of `repos/`, and the seats
10
+ * browsed the rig's other clones and the INSTALLED package's `.d.ts` files as if they were sources.
11
+ *
12
+ * The scope is a first-class input now: a PROJECT (default: every registered `crew.repo` member)
13
+ * or an explicit repo list. From it this module derives the three things the engine is handed
14
+ * (`wicked-core-ts chatOpen(chatId, clis, cwd, scopeJson)`):
15
+ *
16
+ * - `cwd` — a PRIVATE SCRATCH ROOT of the chat's own, under THIS daemon's per-process
17
+ * namespace (`<os tmp>/wicked-crew-chats/<pid>-<random>/<chatId>`, see
18
+ * {@link chatScratchBase}), never a repo and never the daemon's cwd. Relative
19
+ * writes land there and the
20
+ * OS sandbox, where the seat's config arms it, contains writes to it — the
21
+ * repos are READ. Deliberately NOT under the state home: the engine's worker
22
+ * fence denies `Read/Edit/Write(<state home>/**)` to every seat (wicked-core
23
+ * `execute_wrapped::deny_rules`), so a seat whose cwd sat there could not read
24
+ * its own working directory — and a new state-home store needs core's registry
25
+ * (`tests/fixtures/state-home-subtrees.json`) released first.
26
+ * - `readRoots` — the scoped repos' REGISTERED root paths (the run worktree read-roots idea,
27
+ * applied to live repos): advertised to a claude seat as the SDK's
28
+ * `additionalDirectories`, recorded on every seat (`GET /chats`).
29
+ * - `codeGraphDb` — the graph the seats' READ-ONLY estate MCP is bound to: the project's
30
+ * co-located graph (`resolveProjectGraphBinding`, DES-GROUNDING-001 — the SAME
31
+ * seam governed runs get) when the chat is filed into a project; a single
32
+ * repo's own graph otherwise; none for several repos with no project.
33
+ *
34
+ * and STATES it: an `AGENTS.md` + `CLAUDE.md` in the scratch root (every seat CLI reads its cwd's
35
+ * instructions file — claude `CLAUDE.md`; codex, pi, opencode and copilot `AGENTS.md`) names the
36
+ * repos, their paths, the read-only rule and the grounding, so a seat that starts in an empty
37
+ * directory knows exactly what it was pointed at; the same {@link ChatScope} rides the
38
+ * `POST /chats` response and `GET /chats/:id` for the UI (studio's New Chat).
39
+ */
40
+ import { randomBytes } from 'node:crypto';
41
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
42
+ import { tmpdir } from 'node:os';
43
+ import { basename, dirname, join, resolve, sep } from 'node:path';
44
+ import { codeGraphDb } from '../core/repoPaths.js';
45
+ import { resolveProjectGraphBinding } from '../projects/graph.js';
46
+ let processScratchBase;
47
+ /**
48
+ * `<os tmp>/wicked-crew-chats/<pid>-<random>` — THIS daemon's own namespace, minted once per
49
+ * process (Copilot, #518): a shared `<os tmp>/wicked-crew-chats/<chatId>` could already exist from
50
+ * another daemon running as the same OS user, which would pass every ownership check, overwrite the
51
+ * live chat's statement and remove its root. Chats do not survive a daemon restart (the engine's pool
52
+ * is in memory), so a stale namespace left by a crashed daemon holds nothing live. See the module
53
+ * doc for why the base is not the state home.
54
+ */
55
+ export function chatScratchBase() {
56
+ processScratchBase ??= join(tmpdir(), 'wicked-crew-chats', `${process.pid}-${randomBytes(4).toString('hex')}`);
57
+ return processScratchBase;
58
+ }
59
+ /**
60
+ * The real path of `p` even when `p` does not exist yet: its longest EXISTING ancestor is
61
+ * canonicalized and the remaining segments re-appended — so a path under a symlinked temp dir
62
+ * (macOS: `/var/folders/…` → `/private/var/folders/…`) compares equal to one that was resolved
63
+ * through the link, whether or not the leaf has been created.
64
+ */
65
+ function realish(p) {
66
+ let cur = resolve(p);
67
+ const tail = [];
68
+ for (;;) {
69
+ try {
70
+ const real = realpathSync(cur);
71
+ return tail.length === 0 ? real : join(real, ...tail.reverse());
72
+ }
73
+ catch (err) {
74
+ // Only a MISSING component walks up (hardening, W7): EACCES, ELOOP, ENOTDIR and friends mean
75
+ // the path exists but cannot be resolved — the caller decides whether that refuses the open.
76
+ if (err.code !== 'ENOENT')
77
+ throw err;
78
+ const parent = dirname(cur);
79
+ if (parent === cur)
80
+ return resolve(p);
81
+ tail.push(basename(cur));
82
+ cur = parent;
83
+ }
84
+ }
85
+ }
86
+ /** Lexical containment/equality of two resolved-but-not-canonicalized paths. */
87
+ function lexicallyOverlaps(a, b) {
88
+ const ra = resolve(a);
89
+ const rb = resolve(b);
90
+ return ra === rb || ra.startsWith(rb + sep) || rb.startsWith(ra + sep);
91
+ }
92
+ /** Whether `a` and `b` are the same directory or one contains the other, judged on real paths. */
93
+ function overlaps(a, b) {
94
+ const ra = realish(a);
95
+ const rb = realish(b);
96
+ return ra === rb || ra.startsWith(rb + sep) || rb.startsWith(ra + sep);
97
+ }
98
+ /**
99
+ * ANY registered root that contains, or sits inside, the scratch base breaks the "private, never a
100
+ * repo" guarantee (Copilot, #518): the seat's cwd would be part of a live repository — whether or
101
+ * not that repository is in this chat's scope — and, when it is, that read root would expose every
102
+ * sibling chat's scratch directory. Judged over EVERY registered repo, on every branch (scoped or
103
+ * not). Refused as a 409 — it is an operator setup conflict (a repo registered under the OS temp
104
+ * dir, or a daemon whose TMPDIR sits under a checkout), not a caller error.
105
+ */
106
+ function refuseOverlap(repos, base, inScope, log) {
107
+ for (const r of repos) {
108
+ let clash;
109
+ try {
110
+ clash = overlaps(r.root_path, base);
111
+ }
112
+ catch (err) {
113
+ // A root that EXISTS but cannot be resolved (EACCES, ELOOP, an unmounted volume …). NARROWED
114
+ // (independent review, W7): it refuses the open only when it is IN this chat's scope — the
115
+ // seats would be pointed at a root nobody can prove safe — or when its lexical spelling
116
+ // already overlaps the base; an unrelated unresolvable repo is noted and does not block
117
+ // every chat on the daemon.
118
+ if (inScope.has(r.id) || lexicallyOverlaps(r.root_path, base)) {
119
+ return {
120
+ ok: false,
121
+ status: 409,
122
+ error: `repo '${r.name}' at ${r.root_path} cannot be resolved (${message(err)}), so the chat ` +
123
+ 'scratch base cannot be proven outside it; fix the registration or the mount first.',
124
+ };
125
+ }
126
+ log(`chat scope: registered repo '${r.name}' at ${r.root_path} cannot be resolved (${message(err)}); not in scope, continuing`);
127
+ continue;
128
+ }
129
+ if (clash) {
130
+ return {
131
+ ok: false,
132
+ status: 409,
133
+ error: `repo '${r.name}' is registered at ${r.root_path}, which overlaps this daemon's chat ` +
134
+ `scratch base ${base}; a chat's scratch root can never sit inside a repository (nor a ` +
135
+ 'repository inside the scratch base) — register the repo elsewhere or set TMPDIR for the daemon.',
136
+ };
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ /** The production deps over a live adapter. */
142
+ export function chatScopeDeps(adapter) {
143
+ return {
144
+ listRepos: () => adapter.listRepos(),
145
+ projectRepoRefs: async (projectId) => (await adapter.projectMembers(projectId))
146
+ .filter((m) => m.member_kind === 'crew.repo')
147
+ .map((m) => m.member_ref),
148
+ bindProjectGraph: (projectId, repoRef) => resolveProjectGraphBinding(adapter, projectId, repoRef),
149
+ };
150
+ }
151
+ /** The route's own id rule, re-applied here: the id becomes ONE path segment under the base. */
152
+ const CHAT_ID = /^[A-Za-z0-9._-]+$/;
153
+ function message(err) {
154
+ return err instanceof Error ? err.message : String(err);
155
+ }
156
+ function row(r) {
157
+ return { id: r.id, name: r.name, rootPath: r.root_path };
158
+ }
159
+ /**
160
+ * Decide a chat's scope. Never throws on a caller error: an unknown repo is a 404 naming EVERY
161
+ * missing ref (not just the first), a chat id that cannot name a scratch directory is a 400. A
162
+ * graph that cannot be bound degrades to "no graph" WITH the reason — the same posture as a run
163
+ * launch (`resolveProjectGraphBinding` never indexes; a refresh is an explicit act).
164
+ */
165
+ export async function resolveChatScope(req, deps) {
166
+ const base = resolve(deps.scratchBase ?? chatScratchBase());
167
+ const cwd = resolve(base, req.chatId);
168
+ // `..` and `.` pass the route's character class; a resolved cwd that is not strictly INSIDE the
169
+ // base (one segment down) is refused before anything is created.
170
+ if (!CHAT_ID.test(req.chatId) ||
171
+ !cwd.startsWith(base + sep) ||
172
+ cwd.slice(base.length + 1).includes(sep)) {
173
+ return {
174
+ ok: false,
175
+ status: 400,
176
+ error: `chatId ${JSON.stringify(req.chatId)} cannot name a chat scratch directory`,
177
+ };
178
+ }
179
+ const repos = await deps.listRepos();
180
+ const log = deps.log ?? (() => undefined);
181
+ const refs = [...new Set(req.repoRefs)];
182
+ if (refs.length > 0) {
183
+ const found = [];
184
+ const missing = [];
185
+ for (const ref of refs) {
186
+ const byId = repos.find((r) => r.id === ref);
187
+ if (byId !== undefined) {
188
+ if (!found.some((f) => f.id === byId.id))
189
+ found.push(byId);
190
+ continue;
191
+ }
192
+ // A NAME is a human spelling, not an identifier: two checkouts can share one (Copilot,
193
+ // #518). One match resolves; several are refused naming the ids — the same rule the
194
+ // interactive grounding resolver applies — never "whichever row came first".
195
+ const byName = repos.filter((r) => r.name === ref);
196
+ if (byName.length > 1) {
197
+ return {
198
+ ok: false,
199
+ status: 400,
200
+ error: `repoRef '${ref}' is ambiguous — ${byName.length} registered repos share that name ` +
201
+ `(${byName.map((r) => `'${r.id}' at ${r.root_path}`).join(', ')}); name the repo by id.`,
202
+ };
203
+ }
204
+ if (byName.length === 1) {
205
+ if (!found.some((f) => f.id === byName[0].id))
206
+ found.push(byName[0]);
207
+ }
208
+ else {
209
+ missing.push(ref);
210
+ }
211
+ }
212
+ if (missing.length > 0) {
213
+ return {
214
+ ok: false,
215
+ status: 404,
216
+ error: `Repo ${missing.map((m) => `'${m}'`).join(', ')} not found`,
217
+ missing,
218
+ };
219
+ }
220
+ // Every registered repo, with THIS chat's repos in scope: the base must not be part of any.
221
+ const overlap = refuseOverlap(repos, base, new Set(found.map((r) => r.id)), log);
222
+ if (overlap !== null)
223
+ return overlap;
224
+ const graph = await graphForRepos(found, req.projectId, deps);
225
+ return ok({
226
+ kind: 'repos',
227
+ ...(req.projectId !== undefined ? { projectId: req.projectId } : {}),
228
+ repos: found.map(row),
229
+ cwd,
230
+ graph: graph.wire,
231
+ dangling: [],
232
+ }, graph.dbPath, found);
233
+ }
234
+ if (req.projectId !== undefined) {
235
+ const memberRefs = await deps.projectRepoRefs(req.projectId);
236
+ const found = [];
237
+ const dangling = [];
238
+ for (const ref of memberRefs) {
239
+ const repo = repos.find((r) => r.id === ref);
240
+ if (repo === undefined)
241
+ dangling.push(ref);
242
+ else
243
+ found.push(repo);
244
+ }
245
+ const overlap = refuseOverlap(repos, base, new Set(found.map((r) => r.id)), log);
246
+ if (overlap !== null)
247
+ return overlap;
248
+ const decision = await bindOrExplain(deps, req.projectId, undefined);
249
+ return ok({
250
+ kind: 'project',
251
+ projectId: req.projectId,
252
+ repos: found.map(row),
253
+ cwd,
254
+ graph: graphWire(decision),
255
+ dangling,
256
+ }, decision.binding?.dbPath ?? null, found);
257
+ }
258
+ const overlap = refuseOverlap(repos, base, new Set(), log);
259
+ if (overlap !== null)
260
+ return overlap;
261
+ return ok({
262
+ kind: 'none',
263
+ repos: [],
264
+ cwd,
265
+ graph: {
266
+ bound: false,
267
+ reason: 'the chat names no project and no repos, so its seats see only their own scratch root ' +
268
+ 'and no code graph; pass projectId or repoRefs to scope it.',
269
+ },
270
+ dangling: [],
271
+ }, null, []);
272
+ }
273
+ function ok(scope, dbPath, repos) {
274
+ return {
275
+ ok: true,
276
+ scope,
277
+ // Read roots ride RESOLVED (independent review, item 5): a registry spelling with a trailing
278
+ // separator or a `.` segment would otherwise reach the engine's validator verbatim.
279
+ engine: { cwd: scope.cwd, codeGraphDb: dbPath, readRoots: repos.map((r) => resolve(r.root_path)) },
280
+ };
281
+ }
282
+ async function bindOrExplain(deps, projectId, repoRef) {
283
+ try {
284
+ return await deps.bindProjectGraph(projectId, repoRef);
285
+ }
286
+ catch (err) {
287
+ // Resolving the binding is an ENHANCEMENT to the chat; a failure is stated, never fatal.
288
+ return {
289
+ binding: null,
290
+ reason: `the project graph binding could not be resolved (${message(err)}). ` +
291
+ 'This chat gets no code graph.',
292
+ };
293
+ }
294
+ }
295
+ /**
296
+ * The wire half of a binding decision. `repoLabel` (the estate label the project graph indexes a
297
+ * repo under — set by a REPO-BOUND decision) rides along for the UI and the statement; the engine
298
+ * side needs only `dbPath`: a run hands the label so the engine can confirm the graph holds ITS
299
+ * worktree, and a chat has no worktree — the decision already confirmed the graph holds the repo
300
+ * (Copilot, #518).
301
+ */
302
+ function graphWire(decision) {
303
+ return {
304
+ bound: decision.binding !== null,
305
+ reason: decision.reason,
306
+ ...(decision.binding?.repoLabel !== undefined ? { repoLabel: decision.binding.repoLabel } : {}),
307
+ };
308
+ }
309
+ async function graphForRepos(repos, projectId, deps) {
310
+ if (projectId !== undefined) {
311
+ // Filed into a project: the project's co-located graph, repo-bound when there is exactly one
312
+ // repo (so the binding confirms the graph HOLDS it, as a repo-bound run does).
313
+ const decision = await bindOrExplain(deps, projectId, repos.length === 1 ? repos[0].id : undefined);
314
+ if (decision.binding !== null || repos.length !== 1) {
315
+ return { wire: graphWire(decision), dbPath: decision.binding?.dbPath ?? null };
316
+ }
317
+ // The project declined to bind ONE repo (not a member, not indexed yet, …): its reason says
318
+ // "uses its own repo's code graph", so DO that — the same existence-checked fallback the
319
+ // no-project path takes — rather than hand the engine no graph behind a reason that promises
320
+ // one (Copilot, #518). Both reasons are kept: why the project graph was declined, and what
321
+ // the chat actually got.
322
+ const own = ownGraph(repos[0]);
323
+ return {
324
+ wire: { bound: own.wire.bound, reason: `${decision.reason} ${own.wire.reason}` },
325
+ dbPath: own.dbPath,
326
+ };
327
+ }
328
+ if (repos.length === 1) {
329
+ return ownGraph(repos[0]);
330
+ }
331
+ return {
332
+ wire: {
333
+ bound: false,
334
+ reason: `${repos.length} repos and no project: there is no co-located graph to bind. File the ` +
335
+ 'chat into a project (projectId) whose graph is refreshed to ground it across repos; ' +
336
+ 'this chat gets no code graph.',
337
+ },
338
+ dbPath: null,
339
+ };
340
+ }
341
+ /** A single repo's OWN graph — bound only when the registered graph file exists. */
342
+ function ownGraph(only) {
343
+ try {
344
+ const dbPath = codeGraphDb(only);
345
+ // The registry field says WHERE the repo's graph lives, not that it was ever built
346
+ // (Copilot, #518): a repo indexed by nothing would otherwise be reported as grounded while
347
+ // the estate MCP answers "not found" about everything. Existence is the honest floor here
348
+ // (the project path gets the same from `projectGraphStatus`).
349
+ if (!existsSync(dbPath)) {
350
+ return {
351
+ wire: {
352
+ bound: false,
353
+ reason: `'${only.name}' has no code graph built yet (nothing at its registered graph path); ` +
354
+ 'index the repo (wicked-estate index / onboarding) to ground this chat. This chat gets none.',
355
+ },
356
+ dbPath: null,
357
+ };
358
+ }
359
+ return {
360
+ wire: { bound: true, reason: `bound to '${only.name}'s own code graph.` },
361
+ dbPath,
362
+ };
363
+ }
364
+ catch (err) {
365
+ return {
366
+ wire: {
367
+ bound: false,
368
+ reason: `'${only.name}' has no resolvable code graph (${message(err)}); this chat gets none.`,
369
+ },
370
+ dbPath: null,
371
+ };
372
+ }
373
+ }
374
+ /**
375
+ * The statement every seat reads on start — written as BOTH `AGENTS.md` (codex, pi, opencode,
376
+ * copilot) and `CLAUDE.md` (claude) into the scratch root. Plain prose, repo paths verbatim: the
377
+ * seat is in an empty directory and this is how it learns what it was pointed at.
378
+ */
379
+ export function chatScopeStatement(chatId, scope) {
380
+ const lines = [
381
+ '# Chat scope',
382
+ '',
383
+ `This directory is the scratch root of wicked-crew chat \`${chatId}\`. It is the ONLY place you may write.`,
384
+ '',
385
+ ];
386
+ if (scope.repos.length === 0 && scope.kind === 'project') {
387
+ // A project whose every `crew.repo` member is dangling (or that has none): filed, but nothing
388
+ // readable — say exactly that, never "opened without a project" (Copilot, #518).
389
+ lines.push('## Repositories in scope', '', `None readable. This chat is filed into project \`${scope.projectId ?? ''}\`, but no registered`, 'repository of that project could be resolved' +
390
+ (scope.dangling.length > 0
391
+ ? ` (members whose repository is no longer registered: ${scope.dangling.map((d) => `\`${d}\``).join(', ')}).`
392
+ : ' (it has no `crew.repo` member).'), 'Answer from general knowledge, and say plainly that no repository is readable if asked about code.', '');
393
+ }
394
+ else if (scope.repos.length === 0) {
395
+ lines.push('## Repositories in scope', '', 'None. This chat was opened without a project or repo scope: answer from general knowledge,', 'and say plainly that no repository is in scope if asked about code.', '');
396
+ }
397
+ else {
398
+ lines.push('## Repositories in scope (READ-ONLY)', '', ...scope.repos.map((r) => `- **${r.name}** (\`${r.id}\`): \`${r.rootPath}\``), '', 'Explore and answer questions about these repositories by reading them at the paths above.', 'Cite files by their full path. Do NOT modify, create or delete anything inside them — this', 'chat is read-only exploration; write scratch files only under this directory. Nothing', 'outside these repositories and this directory is in scope.', '');
399
+ }
400
+ if (scope.dangling.length > 0 && scope.repos.length > 0) {
401
+ lines.push(`Project members whose repository is no longer registered (not readable): ${scope.dangling
402
+ .map((d) => `\`${d}\``)
403
+ .join(', ')}.`, '');
404
+ }
405
+ lines.push('## Code graph', '');
406
+ lines.push(scope.graph.bound
407
+ ? 'A READ-ONLY wicked-estate MCP server over the code graph is attached to this session: use its ' +
408
+ 'search / blast-radius / lineage tools to find symbols and relationships before grepping. ' +
409
+ (scope.graph.repoLabel !== undefined
410
+ ? `The repository is indexed under the label \`${scope.graph.repoLabel}\`. `
411
+ : '') +
412
+ `(${scope.graph.reason})`
413
+ : `No code graph is attached: ${scope.graph.reason}`, '');
414
+ if (scope.projectId !== undefined) {
415
+ lines.push('## Project', '', `This chat is filed into project \`${scope.projectId}\`.`, '');
416
+ }
417
+ return `${lines.join('\n')}\n`;
418
+ }
419
+ /**
420
+ * Create the chat's scratch root (private) and write the scope statement into it. Throws on an
421
+ * unwritable temp — the route reports that rather than opening seats with no working directory.
422
+ *
423
+ * The root lives under the shared system temp dir, where another local user can pre-place an
424
+ * entry under a predictable name (Copilot, #518) — so nothing here follows a link or trusts an
425
+ * existing entry: the base and the root must be REAL directories (never symlinks), an existing
426
+ * root must be OWNED by this process's user (POSIX), the mode is ENFORCED with `chmod` (mkdir's
427
+ * `mode` applies only on creation), and each instruction file is unlinked before it is written
428
+ * exclusively (`wx`) so a planted link cannot redirect the write.
429
+ */
430
+ export function prepareChatScratch(chatId, scope) {
431
+ const base = resolve(scope.cwd, '..');
432
+ // The WHOLE chain below the OS temp dir is created and validated one segment at a time (Copilot,
433
+ // #518): a recursive mkdir would follow a planted symlink at `<tmp>/wicked-crew-chats` (the
434
+ // parent every daemon's namespace shares) and create the "private" root inside its target. The
435
+ // OS temp dir itself is trusted as the platform's (macOS's `/var -> /private/var` is root-owned).
436
+ const stop = resolve(tmpdir());
437
+ const chain = [];
438
+ for (let dir = base; dir !== stop && dirname(dir) !== dir; dir = dirname(dir))
439
+ chain.unshift(dir);
440
+ if (chain.length === 0 || !base.startsWith(stop + sep)) {
441
+ throw new Error(`refusing chat scratch base ${base}: not below the OS temp dir ${stop}`);
442
+ }
443
+ for (const dir of chain) {
444
+ // Non-recursive on purpose (a recursive mkdir resolves the whole parent chain through any
445
+ // link): an existing segment is fine — `EEXIST` alone is ignored (independent review, W3) —
446
+ // and EVERY segment, existing or not, must then be a real directory owned by this user.
447
+ try {
448
+ mkdirSync(dir, { recursive: false, mode: 0o700 });
449
+ }
450
+ catch (err) {
451
+ if (err.code !== 'EEXIST')
452
+ throw err;
453
+ }
454
+ assertRealOwnedDirectory(dir, 'chat scratch base');
455
+ }
456
+ // Only a root THIS call created is removed on a later failure — a pre-existing entry (a planted
457
+ // directory the ownership check refuses) is never deleted on its planter's behalf (Copilot,
458
+ // #518). `created` is the non-recursive mkdir's OWN verdict (hardening, W-TOCTOU): a separate
459
+ // `existsSync` left a window in which another same-user process could create the root between
460
+ // the check and the mkdir and then lose it to this invocation's cleanup.
461
+ let created = false;
462
+ try {
463
+ mkdirSync(scope.cwd, { recursive: false, mode: 0o700 });
464
+ created = true;
465
+ }
466
+ catch (err) {
467
+ if (err.code !== 'EEXIST')
468
+ throw err;
469
+ }
470
+ try {
471
+ assertRealOwnedDirectory(scope.cwd, 'chat scratch root');
472
+ chmodSync(scope.cwd, 0o700);
473
+ const statement = chatScopeStatement(chatId, scope);
474
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
475
+ const file = join(scope.cwd, name);
476
+ rmSync(file, { force: true }); // removes a planted LINK itself, never its target
477
+ writeFileSync(file, statement, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
478
+ }
479
+ }
480
+ catch (err) {
481
+ if (created)
482
+ removeChatScratch(scope.cwd, base);
483
+ throw err;
484
+ }
485
+ }
486
+ /** `path` must be a real directory (no link), owned by this user where the platform can tell. */
487
+ function assertRealOwnedDirectory(path, what) {
488
+ const meta = lstatSync(path);
489
+ if (meta.isSymbolicLink()) {
490
+ throw new Error(`refusing ${what} ${path}: it is a symlink`);
491
+ }
492
+ if (!meta.isDirectory()) {
493
+ throw new Error(`refusing ${what} ${path}: not a directory`);
494
+ }
495
+ if (process.platform !== 'win32' && typeof process.getuid === 'function') {
496
+ const uid = statSync(path).uid;
497
+ if (uid !== process.getuid()) {
498
+ throw new Error(`refusing ${what} ${path}: owned by uid ${uid}, not this daemon's user`);
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Reap the scratch namespaces of DEAD daemons (independent review, W6): every sibling
504
+ * `<pid>-<hex>` directory under the shared parent whose pid no longer exists is removed — judged
505
+ * with the same checks as {@link removeChatScratch} (a real directory, never a link, owned by this
506
+ * user). This daemon's own namespace, a live pid, a pid this user may not signal (another user's
507
+ * daemon), and anything not shaped like a namespace are left alone. Never throws; returns the
508
+ * removed namespaces. Called once at daemon boot.
509
+ */
510
+ export function reapStaleChatNamespaces(parent = dirname(chatScratchBase())) {
511
+ const removed = [];
512
+ let entries;
513
+ try {
514
+ if (lstatSync(parent).isSymbolicLink())
515
+ return removed;
516
+ entries = readdirSync(parent);
517
+ }
518
+ catch {
519
+ return removed;
520
+ }
521
+ for (const name of entries) {
522
+ const m = /^(\d+)-[0-9a-f]+$/.exec(name);
523
+ if (m === null)
524
+ continue;
525
+ const pid = Number(m[1]);
526
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid)
527
+ continue;
528
+ try {
529
+ process.kill(pid, 0); // alive (or at least present): keep
530
+ continue;
531
+ }
532
+ catch (err) {
533
+ if (err.code !== 'ESRCH')
534
+ continue; // EPERM: someone else's; keep
535
+ }
536
+ const dir = join(parent, name);
537
+ try {
538
+ const meta = lstatSync(dir);
539
+ if (meta.isSymbolicLink() || !meta.isDirectory())
540
+ continue;
541
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && statSync(dir).uid !== process.getuid()) {
542
+ continue;
543
+ }
544
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
545
+ removed.push(dir);
546
+ }
547
+ catch {
548
+ // vanished or unreadable meanwhile: leave it
549
+ }
550
+ }
551
+ return removed;
552
+ }
553
+ /**
554
+ * Remove a chat's scratch root — fail closed (Copilot, #518): the base must be a REAL directory
555
+ * (a planted `<tmp>/wicked-crew-chats` link would make a lexical prefix test pass and the
556
+ * recursive removal follow it), the target must sit DIRECTLY under the base by realpath, must not
557
+ * itself be a link, and must be owned by this user. Anything else is left alone: a wrong guess
558
+ * here deletes someone else's files, so the policy is the same as `prepareChatScratch`'s.
559
+ */
560
+ export function removeChatScratch(cwd, base = chatScratchBase()) {
561
+ // Every check AND the removal sit under one catch-all (Copilot, #518): a filesystem race between
562
+ // the checks and the removal (the entry replaced or gone) must leave the path untouched and the
563
+ // caller's lifecycle intact — never throw out of a `DELETE` or an engine `chatClosed`.
564
+ try {
565
+ if (lstatSync(base).isSymbolicLink())
566
+ return;
567
+ const baseReal = realpathSync(base);
568
+ const target = resolve(cwd);
569
+ const meta = lstatSync(target);
570
+ if (meta.isSymbolicLink() || !meta.isDirectory())
571
+ return;
572
+ if (realpathSync(dirname(target)) !== baseReal)
573
+ return;
574
+ if (process.platform !== 'win32' && typeof process.getuid === 'function' && statSync(target).uid !== process.getuid()) {
575
+ return;
576
+ }
577
+ rmSync(target, { recursive: true, force: true, maxRetries: 3 });
578
+ }
579
+ catch {
580
+ // no base, already gone, or unresolvable: nothing of ours is provably here — leave it
581
+ }
582
+ }
583
+ /**
584
+ * The daemon's live chat scopes, keyed by chat id — in memory, like the engine's own chat pool (a
585
+ * daemon restart loses both). A small state machine (Copilot, #518) rather than a map, because the
586
+ * open is asynchronous and the engine's close is an event:
587
+ *
588
+ * - `reserve` claims an id SYNCHRONOUSLY (before the route's first `await`) and hands back a
589
+ * token; `set` publishes the scope only if THAT reservation is still standing — an engine
590
+ * `chatClosed` or a `DELETE` that cancelled it in the meantime makes `set` return `false`, and
591
+ * the route tears the just-opened chat down instead of registering a scope for a closed one.
592
+ * - `beginClose` (DELETE) removes the root at once and parks the id as `closing`; `closed` (the
593
+ * engine's `chatClosed`) frees it. Until then a re-open of the same id is refused, so the
594
+ * engine's close — which may be delivered after `DELETE` returns — can never delete a newer
595
+ * chat's root, and a `chatClosed` for a merely reserved id cancels that open.
596
+ * - a `closing` slot whose event never arrives (an engine without the event, a lost relay) is
597
+ * freed after `closeGraceMs`; a late event after THAT is the documented residual (the engine's
598
+ * `chatClosed` carries no per-open token).
599
+ */
600
+ export class ChatScopeIndex {
601
+ base;
602
+ closeGraceMs;
603
+ slots = new Map();
604
+ nextToken = 1;
605
+ /** `base`: where this daemon's chat scratch roots live — the resolver is handed the SAME base. */
606
+ constructor(base = chatScratchBase(), closeGraceMs = 5000) {
607
+ this.base = base;
608
+ this.closeGraceMs = closeGraceMs;
609
+ }
610
+ /** Live, reserved or closing — every state in which the id is not free. */
611
+ has(chatId) {
612
+ return this.slots.has(chatId);
613
+ }
614
+ /** The state the id is in, for the route's refusal wording. */
615
+ stateOf(chatId) {
616
+ return this.slots.get(chatId)?.state ?? 'free';
617
+ }
618
+ /** Reserve `chatId` for an open in flight; `null` when it is live, reserved or closing. */
619
+ reserve(chatId) {
620
+ if (this.slots.has(chatId))
621
+ return null;
622
+ const token = this.nextToken++;
623
+ this.slots.set(chatId, { state: 'reserved', token });
624
+ return token;
625
+ }
626
+ /** Give a reservation back (the open failed before `set`) — only the holder's own. */
627
+ release(chatId, token) {
628
+ const slot = this.slots.get(chatId);
629
+ if (slot?.state === 'reserved' && slot.token === token)
630
+ this.slots.delete(chatId);
631
+ }
632
+ /**
633
+ * Abort an open that already reached the ENGINE (the route closed the engine chat again): the
634
+ * engine's `chatClosed` for this id is still on its way, so the id is parked as `closing` — not
635
+ * released — until that event (or the grace) frees it; a reuse in between would otherwise have
636
+ * its root removed by the late event (Copilot, #518). Only the holder's own reservation.
637
+ */
638
+ abortToClosing(chatId, token) {
639
+ const slot = this.slots.get(chatId);
640
+ if (slot?.state !== 'reserved' || slot.token !== token)
641
+ return;
642
+ const timer = setTimeout(() => {
643
+ if (this.slots.get(chatId)?.state === 'closing')
644
+ this.slots.delete(chatId);
645
+ }, this.closeGraceMs);
646
+ timer.unref?.();
647
+ this.slots.set(chatId, { state: 'closing', timer });
648
+ }
649
+ /** Publish the scope of a finished open. `false` when the reservation is gone (cancelled by a
650
+ * close in the meantime): the caller must tear the chat down, nothing was recorded. */
651
+ set(chatId, scope, token) {
652
+ const slot = this.slots.get(chatId);
653
+ if (slot?.state !== 'reserved' || slot.token !== token)
654
+ return false;
655
+ this.slots.set(chatId, { state: 'live', scope });
656
+ return true;
657
+ }
658
+ /** The scope of a LIVE chat; `undefined` while reserved, closing or unknown. */
659
+ get(chatId) {
660
+ const slot = this.slots.get(chatId);
661
+ return slot?.state === 'live' ? slot.scope : undefined;
662
+ }
663
+ /**
664
+ * `DELETE /chats/:id`: remove the root now and park the id until the engine's `chatClosed` (or
665
+ * the grace) frees it. A reserved id is cancelled instead (the in-flight open cleans up on its
666
+ * failed `set`). Returns the scope that was live, if any.
667
+ */
668
+ beginClose(chatId) {
669
+ const slot = this.slots.get(chatId);
670
+ if (slot === undefined || slot.state === 'closing')
671
+ return undefined;
672
+ if (slot.state === 'live')
673
+ removeChatScratch(slot.scope.cwd, this.base);
674
+ // A RESERVED id is parked too, not freed (Copilot, #518): the in-flight open will find its
675
+ // reservation gone, tear the engine chat down and that close's `chatClosed` is still to come —
676
+ // a reuse before then would lose its own chat to it. The grace covers an open that never
677
+ // reached the engine (no event will ever come).
678
+ const timer = setTimeout(() => {
679
+ if (this.slots.get(chatId)?.state === 'closing')
680
+ this.slots.delete(chatId);
681
+ }, this.closeGraceMs);
682
+ timer.unref?.();
683
+ this.slots.set(chatId, { state: 'closing', timer });
684
+ return slot.state === 'live' ? slot.scope : undefined;
685
+ }
686
+ /**
687
+ * The engine's `chatClosed` for `chatId` (any reason): frees a closing id, removes and frees a
688
+ * live one (an engine-side reap), cancels a reservation (the open in flight tears down). Idempotent.
689
+ */
690
+ closed(chatId) {
691
+ const slot = this.slots.get(chatId);
692
+ if (slot === undefined)
693
+ return;
694
+ if (slot.state === 'reserved') {
695
+ // An open is in flight: its `set` will now fail and it tears its engine chat down, whose own
696
+ // `chatClosed` is still to come — park the id (as `beginClose` does) rather than free it, so a
697
+ // reuse in between cannot lose its chat to that teardown (hardening, Copilot seventh pass).
698
+ const timer = setTimeout(() => {
699
+ if (this.slots.get(chatId)?.state === 'closing')
700
+ this.slots.delete(chatId);
701
+ }, this.closeGraceMs);
702
+ timer.unref?.();
703
+ this.slots.set(chatId, { state: 'closing', timer });
704
+ return;
705
+ }
706
+ if (slot.state === 'closing')
707
+ clearTimeout(slot.timer);
708
+ if (slot.state === 'live')
709
+ removeChatScratch(slot.scope.cwd, this.base);
710
+ this.slots.delete(chatId);
711
+ }
712
+ /** Forget the chat and remove its scratch root at once — no closing window (tests, teardown). */
713
+ delete(chatId) {
714
+ const slot = this.slots.get(chatId);
715
+ const scope = slot?.state === 'live' ? slot.scope : undefined;
716
+ if (slot?.state === 'closing')
717
+ clearTimeout(slot.timer);
718
+ this.slots.delete(chatId);
719
+ if (scope !== undefined)
720
+ removeChatScratch(scope.cwd, this.base);
721
+ return scope;
722
+ }
723
+ }
724
+ //# sourceMappingURL=chat-scope.js.map