wicked-crew 0.7.27 → 0.7.28

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 (64) hide show
  1. package/dist/api/routes.d.ts +8 -0
  2. package/dist/api/routes.d.ts.map +1 -1
  3. package/dist/api/routes.js +9 -2
  4. package/dist/api/routes.js.map +1 -1
  5. package/dist/api/server.d.ts +13 -0
  6. package/dist/api/server.d.ts.map +1 -1
  7. package/dist/api/server.js +14 -0
  8. package/dist/api/server.js.map +1 -1
  9. package/dist/cli/index.js +46 -18
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/core/adapter.d.ts.map +1 -1
  12. package/dist/core/adapter.js +22 -18
  13. package/dist/core/adapter.js.map +1 -1
  14. package/dist/core/deliver.d.ts +26 -10
  15. package/dist/core/deliver.d.ts.map +1 -1
  16. package/dist/core/deliver.js +27 -11
  17. package/dist/core/deliver.js.map +1 -1
  18. package/dist/core/deliverable-floor.d.ts.map +1 -1
  19. package/dist/core/deliverable-floor.js +5 -4
  20. package/dist/core/deliverable-floor.js.map +1 -1
  21. package/dist/interactive/bridge-pool.d.ts +130 -2
  22. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  23. package/dist/interactive/bridge-pool.js +281 -8
  24. package/dist/interactive/bridge-pool.js.map +1 -1
  25. package/dist/interactive/bus-location.d.ts +65 -0
  26. package/dist/interactive/bus-location.d.ts.map +1 -0
  27. package/dist/interactive/bus-location.js +75 -0
  28. package/dist/interactive/bus-location.js.map +1 -0
  29. package/dist/interactive/chat-events.d.ts.map +1 -1
  30. package/dist/interactive/chat-events.js +29 -23
  31. package/dist/interactive/chat-events.js.map +1 -1
  32. package/dist/interactive/demo-events.d.ts +16 -1
  33. package/dist/interactive/demo-events.d.ts.map +1 -1
  34. package/dist/interactive/demo-events.js +262 -43
  35. package/dist/interactive/demo-events.js.map +1 -1
  36. package/dist/interactive/doc-grounding.d.ts +202 -0
  37. package/dist/interactive/doc-grounding.d.ts.map +1 -0
  38. package/dist/interactive/doc-grounding.js +647 -0
  39. package/dist/interactive/doc-grounding.js.map +1 -0
  40. package/dist/interactive/draft-events.d.ts +54 -1
  41. package/dist/interactive/draft-events.d.ts.map +1 -1
  42. package/dist/interactive/draft-events.js +234 -117
  43. package/dist/interactive/draft-events.js.map +1 -1
  44. package/dist/interactive/edit-events.d.ts.map +1 -1
  45. package/dist/interactive/edit-events.js +27 -21
  46. package/dist/interactive/edit-events.js.map +1 -1
  47. package/dist/interactive/proxy-routes.d.ts +78 -0
  48. package/dist/interactive/proxy-routes.d.ts.map +1 -1
  49. package/dist/interactive/proxy-routes.js +355 -14
  50. package/dist/interactive/proxy-routes.js.map +1 -1
  51. package/dist/interactive/repo-snapshot.d.ts +31 -0
  52. package/dist/interactive/repo-snapshot.d.ts.map +1 -1
  53. package/dist/interactive/repo-snapshot.js +56 -1
  54. package/dist/interactive/repo-snapshot.js.map +1 -1
  55. package/dist/qe/conformance.d.ts +10 -1
  56. package/dist/qe/conformance.d.ts.map +1 -1
  57. package/dist/qe/conformance.js +57 -4
  58. package/dist/qe/conformance.js.map +1 -1
  59. package/dist/studio/assets/index-CdmCMh2f.js +539 -0
  60. package/dist/studio/index.html +1 -1
  61. package/dist/studio/testid-inventory.json +125 -5
  62. package/endpoint-manifest.json +16 -1
  63. package/package.json +3 -3
  64. package/dist/studio/assets/index-FcZg3WDR.js +0 -539
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Doc → SUBJECT-REPO grounding for the interactive seams (acceptance findings F-046 + follow-up).
3
+ *
4
+ * WHAT WAS WRONG: a document created in a multi-repo project was grounded on the project's FIRST
5
+ * `crew.repo` member — a brochure about wicked-studio was drafted against a wicked-core snapshot,
6
+ * the brief's "use the wicked-studio repo in this project" had no way to reach the seam, and the
7
+ * thread never said which repository the worker actually saw. The bridge's create wire carries
8
+ * `name/kind/brief/style/project` only and builds `doc.created` explicitly (server.js `POST
9
+ * /api/docs`), so a repo named on the create request never rides the bus: crew has to remember it
10
+ * itself, keyed by the document id the bridge answers with.
11
+ *
12
+ * Three things live here, shared by the proxy's create interception and the draft/demo seams:
13
+ *
14
+ * 1. THE REQUEST GRAMMAR — `repo_ref` (one) / `repo_refs` (several) on the create body, each a
15
+ * registered repo id, its registry name, or its root directory's basename; `style` from the
16
+ * bridge's own set, or inferred from the brief's format words when the client sent none
17
+ * (`inferDocStyle`) so a print/A4 brief reaches the bridge's print instructions instead of
18
+ * defaulting to `web`.
19
+ * 2. THE RESOLUTION RULE (`resolveGroundingRepos`) — in preference order: the repos NAMED on the
20
+ * request; else the member repos the BRIEF names by name; else the project's SOLE repo; else
21
+ * NONE — never a silent first-member substitution. A named repo that is not (or no longer) a
22
+ * member is reported as `missing`, so the seam can say so on the thread and the proxy can
23
+ * refuse the create up front.
24
+ * 3. THE DURABLE BINDING (`DocGroundingStore`) — a `crew-grounding.json` sidecar BESIDE THE DOC,
25
+ * `<docs root>/<doc>/crew-grounding.json`, written atomically (tmp + rename, the handoff-ledger
26
+ * discipline): the proxy records `{project, repo_refs, style}` under the doc name the bridge's
27
+ * create answered with, the seams read it when `doc.created` arrives. The bridge emits
28
+ * `doc.created` BEFORE it answers the create, and the seams poll the bus, so a seam can see the
29
+ * event a few ms before the proxy has recorded the binding: `beginCreate`/`settleCreate`/
30
+ * `waitFor` close that window in-process (proxy and seams run in the same daemon) — a seam that
31
+ * finds no binding but sees an unsettled create for the same project waits, bounded, for it.
32
+ */
33
+ import { closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeSync, } from 'node:fs';
34
+ import { randomBytes } from 'node:crypto';
35
+ import { basename, join } from 'node:path';
36
+ // ── Styles ───────────────────────────────────────────────────────────────────────────────────
37
+ /** The bridge's own style set (server.js `POST /api/docs`: anything else is dropped to null → web). */
38
+ export const DOC_STYLES = ['web', 'ppt', 'brochure', 'doc'];
39
+ export function isDocStyle(value) {
40
+ return typeof value === 'string' && DOC_STYLES.includes(value);
41
+ }
42
+ /** Format words → the bridge style they mean. Ordered: explicit slide words beat print words
43
+ * ("a slide deck to print" is a deck), print words beat the prose fallback. Conservative on
44
+ * purpose — an unmatched brief stays `undefined` (the bridge's own `web` default); a wrong guess
45
+ * here would hand the worker the WRONG print contract, which is worse than none. */
46
+ const STYLE_WORDS = [
47
+ ['ppt', /\b(slide ?deck|slides|deck|presentation|pitch ?deck|keynote|powerpoint|pptx)\b/i],
48
+ ['brochure', /\b(brochure|leaflet|flyer|pamphlet|one[- ]pager|print[- ]ready|printable|a4|a3|us letter|letter[- ]size)\b/i],
49
+ ['doc', /\b(memo|white ?paper|briefing note|plain document|prose document)\b/i],
50
+ ];
51
+ /** Infer the bridge style a brief asks for by its format words, or `undefined` when it names none. */
52
+ export function inferDocStyle(brief) {
53
+ for (const [style, re] of STYLE_WORDS)
54
+ if (re.test(brief))
55
+ return style;
56
+ return undefined;
57
+ }
58
+ /** The one-line format contract a style implies — folded into the worker's problem statement so
59
+ * the DRAFT phase (not only the outline's legend) knows what "brochure" must mean on disk. The
60
+ * brochure line is the F-050/F-053 lesson: a print doc rendered as fixed slide pages with
61
+ * `overflow:hidden` clipped its content. */
62
+ export function styleContract(style) {
63
+ switch (style) {
64
+ case 'web':
65
+ return 'a rich, scrollable single web page (responsive; no fixed page or slide size)';
66
+ case 'ppt':
67
+ return 'fixed 16:9 landscape slides — one section per slide, nothing scrolls inside a slide';
68
+ case 'brochure':
69
+ return ('PRINT pages — honour the page size and page count the brief asks for (default A4 portrait), ' +
70
+ 'use @page rules with page breaks between pages, never a fixed slide-size viewport and never ' +
71
+ 'overflow:hidden that clips content');
72
+ case 'doc':
73
+ return 'minimal content-first prose in a single column, no decorative chrome';
74
+ default:
75
+ return `the requested style "${style}"`;
76
+ }
77
+ }
78
+ // ── The request grammar ──────────────────────────────────────────────────────────────────────
79
+ /** How many repositories one document may name. A brochure is about a product, not a monorepo
80
+ * census; the cap also bounds how many snapshots one launch can clone. */
81
+ export const REPO_REFS_MAX = 8;
82
+ /** A repo ref as the create body may spell it: a registry id, a repo name, or a root basename.
83
+ * No whitespace or control characters — the ref rides the single-line PTY problem and names a
84
+ * filesystem path segment after sanitizing. */
85
+ const REPO_REF = /^[A-Za-z0-9][A-Za-z0-9._@:/-]{0,199}$/;
86
+ /** The raw spellings a body carried, for a refusal's `requested` — never used for matching. Every
87
+ * refusal carries them, project mismatches included (Copilot on crew#506). */
88
+ export function spelledRefs(body) {
89
+ const out = [];
90
+ const push = (v) => {
91
+ const text = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v);
92
+ if (out.length < REPO_REFS_MAX * 2)
93
+ out.push(text.slice(0, 200));
94
+ };
95
+ if (body['repo_ref'] !== undefined && body['repo_ref'] !== null)
96
+ push(body['repo_ref']);
97
+ if (body['repo_refs'] !== undefined && body['repo_refs'] !== null) {
98
+ if (Array.isArray(body['repo_refs']))
99
+ for (const v of body['repo_refs'])
100
+ push(v);
101
+ else
102
+ push(body['repo_refs']);
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * Read `repo_ref` / `repo_refs` off a create body. Both may be present; the union is de-duplicated
108
+ * in order. `{ok: true, refs: []}` when neither is present — the common case, nothing named.
109
+ */
110
+ export function parseRepoRefs(body) {
111
+ const fail = (error) => ({ ok: false, error, requested: spelledRefs(body) });
112
+ const raw = [];
113
+ if (body['repo_ref'] !== undefined && body['repo_ref'] !== null)
114
+ raw.push(body['repo_ref']);
115
+ if (body['repo_refs'] !== undefined && body['repo_refs'] !== null) {
116
+ if (!Array.isArray(body['repo_refs']))
117
+ return fail('repo_refs must be an array of repository ids');
118
+ raw.push(...body['repo_refs']);
119
+ }
120
+ const refs = [];
121
+ for (const entry of raw) {
122
+ if (typeof entry !== 'string')
123
+ return fail('repo_ref / repo_refs entries must be strings');
124
+ const ref = entry.trim();
125
+ if (ref.length === 0)
126
+ continue;
127
+ if (!REPO_REF.test(ref)) {
128
+ return fail(`repo_ref "${ref.slice(0, 40)}" is not a repository id, name, or directory name`);
129
+ }
130
+ if (!refs.includes(ref))
131
+ refs.push(ref);
132
+ }
133
+ if (refs.length > REPO_REFS_MAX) {
134
+ return fail(`a document may name at most ${REPO_REFS_MAX} repositories (${refs.length} given)`);
135
+ }
136
+ return { ok: true, refs };
137
+ }
138
+ /** The registry name a repo is known by — the `name` column when present, else its root basename. */
139
+ function repoName(repo) {
140
+ if (typeof repo.name === 'string' && repo.name.trim().length > 0)
141
+ return repo.name.trim();
142
+ const base = basename(repo.root_path);
143
+ return base.length > 0 ? base : repo.id;
144
+ }
145
+ /**
146
+ * The project's `crew.repo` members verified against the repo registry (a stale membership whose
147
+ * repo left the registry is skipped — it has no root to snapshot). `[]` when the project has no
148
+ * repo members or the adapter cannot answer (old addon, engine hiccup): every caller degrades to an
149
+ * ungrounded launch, narrated.
150
+ */
151
+ export async function projectRepoCandidates(adapter, projectId, log) {
152
+ try {
153
+ const members = await adapter.projectMembers(projectId);
154
+ const refs = members.filter((m) => m.member_kind === 'crew.repo').map((m) => m.member_ref);
155
+ if (refs.length === 0)
156
+ return [];
157
+ const registry = await adapter.listRepos();
158
+ const out = [];
159
+ for (const ref of refs) {
160
+ const repo = registry.find((r) => r.id === ref);
161
+ if (repo === undefined) {
162
+ log?.(`[interactive] project ${projectId} has repo member ${ref} but the registry does not — skipped for grounding`);
163
+ continue;
164
+ }
165
+ out.push({ repoRef: repo.id, name: repoName(repo), rootPath: repo.root_path });
166
+ }
167
+ return out;
168
+ }
169
+ catch (err) {
170
+ log?.(`[interactive] could not resolve project ${projectId}'s repositories — grounding unavailable: ${err instanceof Error ? err.message : String(err)}`);
171
+ return [];
172
+ }
173
+ }
174
+ /** Does a request ref name this repo? Its registry id, its name, or its root basename — case-
175
+ * insensitive on the human spellings, exact on the id. */
176
+ export function matchRepoRef(ref, repo) {
177
+ if (ref === repo.repoRef)
178
+ return true;
179
+ const lower = ref.toLowerCase();
180
+ return lower === repo.name.toLowerCase() || lower === basename(repo.rootPath).toLowerCase();
181
+ }
182
+ /**
183
+ * EVERY candidate a ref names. An exact registry id is unique by construction and wins alone; a
184
+ * human spelling (name / root basename) can name several repos — two checkouts of `wicked-studio`
185
+ * under different parents share a basename — and the caller must treat >1 as AMBIGUOUS, never
186
+ * pick the first (codex on crew#506).
187
+ */
188
+ export function matchingRepos(ref, candidates) {
189
+ const byId = candidates.find((c) => c.repoRef === ref);
190
+ if (byId !== undefined)
191
+ return [byId];
192
+ return candidates.filter((c) => matchRepoRef(ref, c));
193
+ }
194
+ function escapeRe(s) {
195
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
196
+ }
197
+ /**
198
+ * The member repos a brief names OUTRIGHT — "use the wicked-studio repo in this project" names
199
+ * wicked-studio. Whole-token matches only (a name followed by `-` or an alphanumeric is a
200
+ * different repo: `wicked-studio` must not match `wicked-studio-archived`); case-insensitive.
201
+ * Order follows the candidates, not the brief.
202
+ */
203
+ export function reposNamedInBrief(brief, candidates) {
204
+ if (brief.trim().length === 0)
205
+ return [];
206
+ return candidates.filter((repo) => {
207
+ const names = new Set([repo.name, basename(repo.rootPath)].filter((n) => n.length >= 3));
208
+ for (const name of names) {
209
+ const re = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRe(name)}(?![A-Za-z0-9_-])`, 'i');
210
+ if (re.test(brief))
211
+ return true;
212
+ }
213
+ return false;
214
+ });
215
+ }
216
+ /**
217
+ * Decide which repositories a document is grounded on. The order IS the contract:
218
+ *
219
+ * - `namedRefs` given (the create request said so) → THOSE, with unresolvable ones in `missing`;
220
+ * - else the member repos the brief names by name;
221
+ * - else the project's only repo;
222
+ * - else none — a multi-repo project whose document named nothing is NOT grounded on an arbitrary
223
+ * member (the F-046 defect); the caller narrates how to name one.
224
+ */
225
+ export async function resolveGroundingRepos(adapter, projectId, brief, namedRefs, log) {
226
+ const candidates = await projectRepoCandidates(adapter, projectId, log);
227
+ const memberCount = candidates.length;
228
+ if (namedRefs !== undefined && namedRefs.length > 0) {
229
+ const repos = [];
230
+ const missing = [];
231
+ const ambiguous = [];
232
+ for (const ref of namedRefs) {
233
+ const hits = matchingRepos(ref, candidates);
234
+ if (hits.length === 0)
235
+ missing.push(ref);
236
+ else if (hits.length > 1)
237
+ ambiguous.push(ref);
238
+ else if (!repos.includes(hits[0]))
239
+ repos.push(hits[0]);
240
+ }
241
+ return { repos, source: 'named', missing, ambiguous, memberCount };
242
+ }
243
+ if (candidates.length === 0)
244
+ return { repos: [], source: 'none', missing: [], ambiguous: [], memberCount };
245
+ if (candidates.length === 1)
246
+ return { repos: candidates, source: 'sole-member', missing: [], ambiguous: [], memberCount };
247
+ const fromBrief = reposNamedInBrief(brief, candidates);
248
+ if (fromBrief.length > 0)
249
+ return { repos: fromBrief, source: 'brief', missing: [], ambiguous: [], memberCount };
250
+ return { repos: [], source: 'none', missing: [], ambiguous: [], memberCount };
251
+ }
252
+ /**
253
+ * A safe directory name for a repo's snapshot under `<runDir>/repos/` — derived from the CANONICAL
254
+ * registry id (unique by construction; names and basenames collide — codex on crew#506), reduced
255
+ * to a path-safe charset with its case kept, and made unique among `taken` CASE-INSENSITIVELY so
256
+ * ids `Foo` and `foo` never overwrite each other on a case-insensitive filesystem. The problem
257
+ * statement still calls the repo by its NAME beside the path. Adds the result to `taken`.
258
+ */
259
+ export function snapshotDirName(repo, taken = new Set()) {
260
+ const base = repo.repoRef.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'repo';
261
+ let candidate = base;
262
+ for (let n = 2; [...taken].some((t) => t.toLowerCase() === candidate.toLowerCase()); n += 1) {
263
+ candidate = `${base}-${n}`;
264
+ }
265
+ taken.add(candidate);
266
+ return candidate;
267
+ }
268
+ /** The thread line that says WHERE a launch is grounded and WHY (F-046 follow-up: the worker's
269
+ * own words were the only place "grounded on wicked-core only" appeared). `null` when there is
270
+ * nothing to say about repositories at all (a repo-less project, an unfiled doc). */
271
+ export function groundingNarration(decision, snapshotted, kind) {
272
+ const parts = [];
273
+ if (snapshotted.length > 0) {
274
+ const why = decision.source === 'named'
275
+ ? 'named in your request'
276
+ : decision.source === 'brief'
277
+ ? 'named in your brief'
278
+ : "the project's only repository";
279
+ parts.push(`Grounded on ${snapshotted.map((r) => r.name).join(', ')} (${why}) — the worker reads an offline ` +
280
+ `snapshot of ${snapshotted.length === 1 ? 'it' : 'each'} plus the project's code graph where one is built.`);
281
+ }
282
+ if (decision.missing.length > 0) {
283
+ parts.push(`Requested ${decision.missing.length === 1 ? 'repository' : 'repositories'} ${decision.missing
284
+ .map((m) => `"${m}"`)
285
+ .join(', ')} ${decision.missing.length === 1 ? 'is' : 'are'} not a member of this project — skipped.`);
286
+ }
287
+ if (decision.ambiguous.length > 0) {
288
+ parts.push(`Requested ${decision.ambiguous.length === 1 ? 'repository' : 'repositories'} ${decision.ambiguous
289
+ .map((m) => `"${m}"`)
290
+ .join(', ')} ${decision.ambiguous.length === 1 ? 'names' : 'name'} several repositories in this project — name ` +
291
+ `${decision.ambiguous.length === 1 ? 'it' : 'them'} by repository id; skipped.`);
292
+ }
293
+ if (snapshotted.length === 0 && decision.source === 'none' && decision.memberCount > 1) {
294
+ parts.push(`This project has ${decision.memberCount} repositories and none was named for this ${kind} — working from ` +
295
+ `the brief and the project's code graph only. Name one (repo_ref on the create request, or by name in ` +
296
+ `the brief) to ground the next ${kind} on its source.`);
297
+ }
298
+ return parts.length > 0 ? parts.join(' ') : null;
299
+ }
300
+ // ── The durable binding, beside the doc ──────────────────────────────────────────────────────
301
+ //
302
+ // WHERE IT LIVES — and why NOT under the state home. wicked-core embeds crew's state-home registry
303
+ // (`tests/fixtures/state-home-subtrees.json`, core's `src/state_home.rs`) as the worker Read fence
304
+ // and REFUSES every launch that finds a top-level entry the registry does not classify (fail
305
+ // closed, never widen). A new store under `<state home>/` therefore needs a core release before a
306
+ // single governed run can start beside it. The binding is doc metadata anyway — "this document is
307
+ // about repository X" — so it lives where the doc lives: beside the bridge's own `versions.json`
308
+ // and the `project.json` breadcrumb the bridge writes for exactly the same kind of fact. The bridge
309
+ // never reads it; a retired doc keeps its directory and its name stays reserved (interactive#189),
310
+ // so a stale binding can never be inherited by a same-named successor — no delete sweep needed.
311
+ /** The sidecar's filename, beside `versions.json`. */
312
+ export const CREW_GROUNDING_FILE = 'crew-grounding.json';
313
+ /** Interactive's doc-name grammar, restated (draft-events.ts DOC_NAME imports this module, so it
314
+ * cannot be imported here): a document id is a safe single path segment or it names no path. */
315
+ const SAFE_DOC = /^[a-z0-9][a-z0-9-]{0,63}$/;
316
+ /** Why a sidecar path was refused — each is a containment failure, never a "not found". */
317
+ export class GroundingPathRefusedError extends Error {
318
+ constructor(message) {
319
+ super(message);
320
+ this.name = 'GroundingPathRefusedError';
321
+ }
322
+ }
323
+ /** `O_NOFOLLOW` / `O_DIRECTORY` where the platform has them (POSIX); 0 elsewhere — the lstat/fstat
324
+ * identity checks below still refuse a swapped path there. */
325
+ const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
326
+ const O_DIRECTORY = fsConstants.O_DIRECTORY ?? 0;
327
+ /**
328
+ * A HELD handle on the verified document directory (codex on crew#506, CRITICAL). `O_NOFOLLOW` on
329
+ * the final component protects only that component: replacing the PARENT directory with a symlink
330
+ * after validation would redirect every later read, temp create, rename and unlink through the
331
+ * pathname. Node has no `openat`/`renameat`, so the discipline is: open the directory itself
332
+ * (`O_DIRECTORY|O_NOFOLLOW`), remember its identity (dev/ino), and IMMEDIATELY before every path
333
+ * operation re-`lstat` the directory path and require the same identity — a swap is refused.
334
+ *
335
+ * RESIDUAL (documented, the core v3.5 TOCTOU discipline): the re-check and the operation are two
336
+ * syscalls, so a swap landing between them is a kernel-level window this process cannot close
337
+ * without `*at` syscalls; the exposure is one scheduler slice, the blast radius one sidecar under a
338
+ * docs root only the daemon's own user can write. Platforms that cannot open a directory
339
+ * descriptor (Windows) keep the identity from `lstat` and run the same re-check.
340
+ */
341
+ class DocDirHandle {
342
+ path;
343
+ fd;
344
+ dev;
345
+ ino;
346
+ constructor(path, fd, dev, ino) {
347
+ this.path = path;
348
+ this.fd = fd;
349
+ this.dev = dev;
350
+ this.ino = ino;
351
+ }
352
+ static open(docDir) {
353
+ let fd = null;
354
+ let st;
355
+ try {
356
+ fd = openSync(docDir, fsConstants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
357
+ st = fstatSync(fd);
358
+ }
359
+ catch (err) {
360
+ if (fd !== null)
361
+ closeSync(fd);
362
+ if (err.code === 'ELOOP') {
363
+ throw new GroundingPathRefusedError(`refusing ${docDir}: the document directory is a symlink`);
364
+ }
365
+ // No directory descriptors on this platform (Windows) — identity from lstat instead.
366
+ fd = null;
367
+ st = lstatSync(docDir);
368
+ }
369
+ if (!st.isDirectory()) {
370
+ if (fd !== null)
371
+ closeSync(fd);
372
+ throw new GroundingPathRefusedError(`refusing ${docDir}: not a directory`);
373
+ }
374
+ return new DocDirHandle(docDir, fd, st.dev, st.ino);
375
+ }
376
+ /** Immediately before EVERY path operation: the path must still name the held directory. */
377
+ assertIntact() {
378
+ let st;
379
+ try {
380
+ st = lstatSync(this.path);
381
+ }
382
+ catch (err) {
383
+ throw new GroundingPathRefusedError(`refusing ${this.path}: the document directory vanished (${err instanceof Error ? err.message : String(err)})`);
384
+ }
385
+ if (st.isSymbolicLink() || !st.isDirectory() || st.dev !== this.dev || st.ino !== this.ino) {
386
+ throw new GroundingPathRefusedError(`refusing ${this.path}: the document directory was replaced under the sidecar`);
387
+ }
388
+ }
389
+ close() {
390
+ if (this.fd !== null) {
391
+ try {
392
+ closeSync(this.fd);
393
+ }
394
+ catch {
395
+ /* already closed */
396
+ }
397
+ }
398
+ }
399
+ }
400
+ export class DocGroundingStore {
401
+ pending = new Map();
402
+ nextToken = 1;
403
+ hooks;
404
+ constructor(hooks = {}) {
405
+ this.hooks = hooks;
406
+ }
407
+ /** `<docsRoot>/<documentId>/crew-grounding.json`, or null for an id that is not a safe segment.
408
+ * LEXICAL only — see {@link DocGroundingStore.verifiedSidecar} for the containment-checked path. */
409
+ static sidecarPath(docsRoot, documentId) {
410
+ return SAFE_DOC.test(documentId) ? join(docsRoot, documentId, CREW_GROUNDING_FILE) : null;
411
+ }
412
+ /**
413
+ * The sidecar path anchored to the REAL docs root, every component below it checked (codex on
414
+ * crew#506): the docs root must resolve (`realpath`); the doc directory, when present, must be a
415
+ * real directory — a symlink there would let a planted `<docs root>/<doc>` → elsewhere read or
416
+ * write a sidecar outside the root — and the sidecar (and its rename temp) must not be a link.
417
+ * Returns `{ path, docDir, docDirExists }`; throws {@link GroundingPathRefusedError} on a link or
418
+ * a non-directory; throws the fs error when the root itself does not resolve.
419
+ */
420
+ static verifiedSidecar(docsRoot, documentId) {
421
+ if (!SAFE_DOC.test(documentId))
422
+ throw new GroundingPathRefusedError(`document id "${documentId}" cannot name a workspace path`);
423
+ const realRoot = realpathSync(docsRoot);
424
+ const docDir = join(realRoot, documentId);
425
+ let docDirExists = false;
426
+ try {
427
+ const st = lstatSync(docDir);
428
+ if (st.isSymbolicLink())
429
+ throw new GroundingPathRefusedError(`refusing ${docDir}: the document directory is a symlink`);
430
+ if (!st.isDirectory())
431
+ throw new GroundingPathRefusedError(`refusing ${docDir}: not a directory`);
432
+ docDirExists = true;
433
+ }
434
+ catch (err) {
435
+ if (err instanceof GroundingPathRefusedError)
436
+ throw err;
437
+ if (err.code !== 'ENOENT')
438
+ throw err;
439
+ }
440
+ const path = join(docDir, CREW_GROUNDING_FILE);
441
+ let sidecar = null;
442
+ try {
443
+ sidecar = lstatSync(path);
444
+ if (sidecar.isSymbolicLink())
445
+ throw new GroundingPathRefusedError(`refusing ${path}: the sidecar path is a symlink`);
446
+ if (!sidecar.isFile())
447
+ throw new GroundingPathRefusedError(`refusing ${path}: the sidecar path is not a regular file`);
448
+ }
449
+ catch (err) {
450
+ if (err instanceof GroundingPathRefusedError)
451
+ throw err;
452
+ if (err.code !== 'ENOENT')
453
+ throw err;
454
+ }
455
+ return { path, docDir, docDirExists, sidecar };
456
+ }
457
+ /** The binding beside the doc, or `undefined` when absent, malformed, or REFUSED (a symlinked
458
+ * doc dir / sidecar is read as "nothing named" — the thread narrates the fallback, the daemon
459
+ * never dies over it, and nothing outside the docs root is ever read). */
460
+ get(docsRoot, documentId) {
461
+ let path;
462
+ let expected;
463
+ try {
464
+ ({ path, sidecar: expected } = DocGroundingStore.verifiedSidecar(docsRoot, documentId));
465
+ }
466
+ catch {
467
+ return undefined;
468
+ }
469
+ if (expected === null)
470
+ return undefined;
471
+ let dir;
472
+ try {
473
+ dir = DocDirHandle.open(join(path, '..'));
474
+ }
475
+ catch {
476
+ return undefined;
477
+ }
478
+ let fd = null;
479
+ try {
480
+ this.hooks.afterLstat?.(path);
481
+ // The lstat → open window (codex on crew#506): the PARENT must still be the held directory,
482
+ // then open WITHOUT following links and read through the descriptor only after fstat proves
483
+ // it is the very inode lstat saw — a path swapped for a link (or another file) is refused.
484
+ dir.assertIntact();
485
+ fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW);
486
+ const actual = fstatSync(fd);
487
+ if (!actual.isFile() || actual.dev !== expected.dev || actual.ino !== expected.ino)
488
+ return undefined;
489
+ const row = JSON.parse(readFileSync(fd, 'utf8'));
490
+ if (typeof row !== 'object' || row === null)
491
+ return undefined;
492
+ if (typeof row['project_id'] !== 'string' || row['project_id'].length === 0)
493
+ return undefined;
494
+ const refs = Array.isArray(row['repo_refs'])
495
+ ? row['repo_refs'].filter((r) => typeof r === 'string' && r.length > 0)
496
+ : [];
497
+ return {
498
+ project_id: row['project_id'],
499
+ repo_refs: refs,
500
+ ...(typeof row['style'] === 'string' ? { style: row['style'] } : {}),
501
+ recorded_at: typeof row['recorded_at'] === 'string' ? row['recorded_at'] : new Date(0).toISOString(),
502
+ };
503
+ }
504
+ catch {
505
+ return undefined;
506
+ }
507
+ finally {
508
+ if (fd !== null)
509
+ closeSync(fd);
510
+ dir.close();
511
+ }
512
+ }
513
+ /** Write the sidecar atomically under the VERIFIED real docs root. The doc directory normally
514
+ * exists by now (the bridge created it before answering the create); it is created — as a plain
515
+ * directory directly under the real root, never through a link — when it does not, so a record
516
+ * never fails on ordering alone. Throws ({@link GroundingPathRefusedError} on containment, the
517
+ * fs error otherwise) — the caller logs and the doc stays unbound. */
518
+ record(docsRoot, documentId, binding) {
519
+ const { path, docDir, docDirExists } = DocGroundingStore.verifiedSidecar(docsRoot, documentId);
520
+ const row = { ...binding, recorded_at: binding.recorded_at ?? new Date().toISOString() };
521
+ if (!docDirExists)
522
+ mkdirSync(docDir);
523
+ // Hold the directory for the whole write: every path operation below re-checks it first.
524
+ const dir = DocDirHandle.open(docDir);
525
+ try {
526
+ this.hooks.afterLstat?.(path);
527
+ // The temp name is RANDOM and created EXCLUSIVELY without following links (codex on
528
+ // crew#506): a pre-planted file or link at a predictable name can neither be opened nor
529
+ // followed; the rename then replaces the sidecar path atomically (a link planted there in the
530
+ // meantime is replaced as a link — its target is never written).
531
+ const tmp = join(docDir, `.${CREW_GROUNDING_FILE}.${randomBytes(8).toString('hex')}.tmp`);
532
+ dir.assertIntact();
533
+ const fd = openSync(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW, 0o600);
534
+ try {
535
+ writeSync(fd, JSON.stringify(row, null, 2), null, 'utf8');
536
+ }
537
+ finally {
538
+ closeSync(fd);
539
+ }
540
+ try {
541
+ dir.assertIntact();
542
+ renameSync(tmp, path);
543
+ }
544
+ catch (err) {
545
+ try {
546
+ dir.assertIntact();
547
+ rmSync(tmp, { force: true });
548
+ }
549
+ catch {
550
+ /* the directory is gone or replaced — nothing of ours to clean through that path */
551
+ }
552
+ throw err;
553
+ }
554
+ }
555
+ finally {
556
+ dir.close();
557
+ }
558
+ }
559
+ /** Drop a document's sidecar. `true` when one was removed; a refused path removes nothing. */
560
+ remove(docsRoot, documentId) {
561
+ let path;
562
+ let docDir;
563
+ let sidecar;
564
+ try {
565
+ ({ path, docDir, sidecar } = DocGroundingStore.verifiedSidecar(docsRoot, documentId));
566
+ }
567
+ catch {
568
+ return false;
569
+ }
570
+ if (sidecar === null)
571
+ return false;
572
+ let dir;
573
+ try {
574
+ dir = DocDirHandle.open(docDir);
575
+ }
576
+ catch {
577
+ return false;
578
+ }
579
+ try {
580
+ this.hooks.afterLstat?.(path);
581
+ dir.assertIntact();
582
+ rmSync(path, { force: true });
583
+ return true;
584
+ }
585
+ catch {
586
+ return false;
587
+ }
588
+ finally {
589
+ dir.close();
590
+ }
591
+ }
592
+ /** The proxy is about to forward a create for `projectId` that WILL record a binding. */
593
+ beginCreate(projectId) {
594
+ const token = this.nextToken++;
595
+ let settle = () => undefined;
596
+ const settled = new Promise((resolve) => {
597
+ settle = resolve;
598
+ });
599
+ this.pending.set(token, { projectId, settled, settle });
600
+ return token;
601
+ }
602
+ /** The create answered (recorded, refused, or failed) — wake every waiter. */
603
+ settleCreate(token) {
604
+ const entry = this.pending.get(token);
605
+ if (entry === undefined)
606
+ return;
607
+ this.pending.delete(token);
608
+ entry.settle();
609
+ }
610
+ /** How many creates for `projectId` are still unanswered (diagnostics / tests). */
611
+ pendingCount(projectId) {
612
+ let n = 0;
613
+ for (const p of this.pending.values())
614
+ if (p.projectId === projectId)
615
+ n += 1;
616
+ return n;
617
+ }
618
+ /**
619
+ * The binding for `documentId` under `docsRoot`, waiting (bounded) for an in-flight create of the
620
+ * same project to settle first — the seam's answer to the bus racing the create response.
621
+ * Resolves immediately when the binding is already there or nothing is pending for the project.
622
+ */
623
+ async waitFor(docsRoot, documentId, projectId, timeoutMs) {
624
+ const deadline = Date.now() + timeoutMs;
625
+ for (;;) {
626
+ const hit = this.get(docsRoot, documentId);
627
+ if (hit !== undefined)
628
+ return hit;
629
+ const waiting = [...this.pending.values()].filter((p) => p.projectId === projectId);
630
+ if (waiting.length === 0)
631
+ return undefined;
632
+ const remaining = deadline - Date.now();
633
+ if (remaining <= 0)
634
+ return undefined;
635
+ let timer;
636
+ await Promise.race([
637
+ Promise.race(waiting.map((p) => p.settled)),
638
+ new Promise((resolve) => {
639
+ timer = setTimeout(resolve, remaining);
640
+ }),
641
+ ]);
642
+ if (timer !== undefined)
643
+ clearTimeout(timer);
644
+ }
645
+ }
646
+ }
647
+ //# sourceMappingURL=doc-grounding.js.map