wicked-crew 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/api/audit.d.ts +13 -0
  2. package/dist/api/audit.d.ts.map +1 -1
  3. package/dist/api/audit.js +18 -2
  4. package/dist/api/audit.js.map +1 -1
  5. package/dist/api/guidance-index.d.ts +39 -0
  6. package/dist/api/guidance-index.d.ts.map +1 -0
  7. package/dist/api/guidance-index.js +67 -0
  8. package/dist/api/guidance-index.js.map +1 -0
  9. package/dist/api/open-path.d.ts +16 -0
  10. package/dist/api/open-path.d.ts.map +1 -1
  11. package/dist/api/open-path.js +22 -0
  12. package/dist/api/open-path.js.map +1 -1
  13. package/dist/api/retry-index.d.ts +30 -0
  14. package/dist/api/retry-index.d.ts.map +1 -0
  15. package/dist/api/retry-index.js +45 -0
  16. package/dist/api/retry-index.js.map +1 -0
  17. package/dist/api/routes.d.ts +53 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +352 -23
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/run-files.d.ts +63 -0
  22. package/dist/api/run-files.d.ts.map +1 -0
  23. package/dist/api/run-files.js +271 -0
  24. package/dist/api/run-files.js.map +1 -0
  25. package/dist/api/server.d.ts +79 -0
  26. package/dist/api/server.d.ts.map +1 -1
  27. package/dist/api/server.js +135 -6
  28. package/dist/api/server.js.map +1 -1
  29. package/dist/api/stall-watchdog.d.ts +62 -0
  30. package/dist/api/stall-watchdog.d.ts.map +1 -0
  31. package/dist/api/stall-watchdog.js +138 -0
  32. package/dist/api/stall-watchdog.js.map +1 -0
  33. package/dist/cli/index.js +78 -13
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/core/adapter.d.ts +24 -10
  36. package/dist/core/adapter.d.ts.map +1 -1
  37. package/dist/core/adapter.js +191 -30
  38. package/dist/core/adapter.js.map +1 -1
  39. package/dist/core/bridge-reaper.d.ts +134 -0
  40. package/dist/core/bridge-reaper.d.ts.map +1 -0
  41. package/dist/core/bridge-reaper.js +286 -0
  42. package/dist/core/bridge-reaper.js.map +1 -0
  43. package/dist/core/deliver.d.ts +118 -0
  44. package/dist/core/deliver.d.ts.map +1 -0
  45. package/dist/core/deliver.js +241 -0
  46. package/dist/core/deliver.js.map +1 -0
  47. package/dist/core/deliverable-floor.d.ts +103 -0
  48. package/dist/core/deliverable-floor.d.ts.map +1 -0
  49. package/dist/core/deliverable-floor.js +173 -0
  50. package/dist/core/deliverable-floor.js.map +1 -0
  51. package/dist/core/exec.d.ts +2 -0
  52. package/dist/core/exec.d.ts.map +1 -1
  53. package/dist/core/exec.js.map +1 -1
  54. package/dist/core/types.d.ts +79 -1
  55. package/dist/core/types.d.ts.map +1 -1
  56. package/dist/core/types.js +3 -0
  57. package/dist/core/types.js.map +1 -1
  58. package/dist/interactive/bridge-pool.d.ts +28 -0
  59. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  60. package/dist/interactive/bridge-pool.js +67 -10
  61. package/dist/interactive/bridge-pool.js.map +1 -1
  62. package/dist/interactive/chat-events.d.ts +207 -0
  63. package/dist/interactive/chat-events.d.ts.map +1 -0
  64. package/dist/interactive/chat-events.js +769 -0
  65. package/dist/interactive/chat-events.js.map +1 -0
  66. package/dist/interactive/demo-events.d.ts +283 -0
  67. package/dist/interactive/demo-events.d.ts.map +1 -0
  68. package/dist/interactive/demo-events.js +889 -0
  69. package/dist/interactive/demo-events.js.map +1 -0
  70. package/dist/interactive/draft-events.d.ts +87 -7
  71. package/dist/interactive/draft-events.d.ts.map +1 -1
  72. package/dist/interactive/draft-events.js +352 -49
  73. package/dist/interactive/draft-events.js.map +1 -1
  74. package/dist/interactive/edit-events.d.ts +22 -0
  75. package/dist/interactive/edit-events.d.ts.map +1 -1
  76. package/dist/interactive/edit-events.js +73 -2
  77. package/dist/interactive/edit-events.js.map +1 -1
  78. package/dist/interactive/repo-snapshot.d.ts +100 -0
  79. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  80. package/dist/interactive/repo-snapshot.js +289 -0
  81. package/dist/interactive/repo-snapshot.js.map +1 -0
  82. package/dist/projects/graph-paths.d.ts +92 -0
  83. package/dist/projects/graph-paths.d.ts.map +1 -0
  84. package/dist/projects/graph-paths.js +130 -0
  85. package/dist/projects/graph-paths.js.map +1 -0
  86. package/dist/projects/graph.d.ts +179 -0
  87. package/dist/projects/graph.d.ts.map +1 -0
  88. package/dist/projects/graph.js +775 -0
  89. package/dist/projects/graph.js.map +1 -0
  90. package/dist/projects/routes.d.ts +7 -0
  91. package/dist/projects/routes.d.ts.map +1 -1
  92. package/dist/projects/routes.js +122 -0
  93. package/dist/projects/routes.js.map +1 -1
  94. package/dist/studio/assets/index-8p8uwCxG.js +530 -0
  95. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  96. package/dist/studio/index.html +5 -3
  97. package/package.json +3 -3
  98. package/dist/studio/assets/index-CCwXa1cn.js +0 -428
  99. package/dist/studio/assets/index-HWxo0h41.css +0 -32
@@ -27,12 +27,21 @@
27
27
  * service instruments fresh ones — so the worker contract explicitly forbids inventing
28
28
  * `data-wid` attributes rather than requiring preservation. The feedback→edit leg (fragment
29
29
  * preservation at scale) is the structural seam next door: edit-events.ts.
30
+ * - UNFILED DOCS ARE ANSWERED TOO: this seam originally rejected `doc.created` frames without
31
+ * a `project_id` ("unbound docs are the assist skill's solo business") — superseded by
32
+ * DES-UX-001 slice U (wicked-studio, §6.2 + §8.4.1 probe 3), which made unfiled docs a
33
+ * first-class path created through crew's synthesized `default` mount with NO project field.
34
+ * Nothing else answers those (BRIEF-UX-001 J3 CRITICAL: the doc sat on its placeholder
35
+ * forever), so the launch simply omits `projectId` — an unfiled governed run (CREW-UX-2).
30
36
  */
31
- import { mkdirSync, existsSync, statSync } from 'node:fs';
37
+ import { resolveProjectGraphBinding } from '../projects/graph.js';
38
+ import { mkdirSync, existsSync, rmSync, statSync } from 'node:fs';
32
39
  import { join } from 'node:path';
33
40
  import { homedir } from 'node:os';
34
41
  import { randomUUID } from 'node:crypto';
35
42
  import { InteractiveHandoffLedger } from './ledger.js';
43
+ import { snapshotRepo } from './repo-snapshot.js';
44
+ import { DELIVERABLE_FLOOR_PHASE_ID } from '../core/deliverable-floor.js';
36
45
  // ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
37
46
  export const INTERACTIVE_DOMAIN = 'wicked-interactive';
38
47
  export const DOC_CREATED = 'wicked.interactive.doc.created';
@@ -112,7 +121,10 @@ export const INTERACTIVE_DRAFT_WORKFLOW_DEF = {
112
121
  /**
113
122
  * Parse a bus frame into a {@link SourceDocCreated}, or `null` when it is not an actionable
114
123
  * `doc.created` (wrong type, non-`source` kind, missing/malformed document_id). `kind: "demo"`
115
- * and plain html docs are the assist loop's business, not this seam's.
124
+ * and plain html docs are the assist loop's business, not this seam's. A missing `project_id`
125
+ * is NOT a rejection: unfiled docs (DES-UX-001 slice U) are actionable with `projectId`
126
+ * undefined — the earlier "unbound docs are the assist skill's solo business" gate is
127
+ * superseded (nothing else answers a doc created through the default mount; BRIEF-UX-001 J3).
116
128
  */
117
129
  export function parseSourceDocCreated(eventType, payload) {
118
130
  if (eventType !== DOC_CREATED)
@@ -131,10 +143,7 @@ export function parseSourceDocCreated(eventType, payload) {
131
143
  : [];
132
144
  const style = typeof p['style'] === 'string' && p['style'].length > 0 ? p['style'] : 'web';
133
145
  const projectId = typeof p['project_id'] === 'string' && p['project_id'].length > 0 ? p['project_id'] : undefined;
134
- // Only project-bound docs route through crew; unbound docs are the assist skill's solo business.
135
- if (projectId === undefined)
136
- return null;
137
- return { documentId, brief, sourcePaths, style, projectId };
146
+ return { documentId, brief, sourcePaths, style, ...(projectId !== undefined ? { projectId } : {}) };
138
147
  }
139
148
  /** Collapse whitespace/newlines to single spaces and cap length — the intent must stay a
140
149
  * single line (the PTY seat runner refuses embedded newlines) and a pasted-novel brief must
@@ -143,18 +152,88 @@ export function oneLine(text, cap) {
143
152
  const flat = text.replace(/\s+/g, ' ').trim();
144
153
  return flat.length > cap ? `${flat.slice(0, cap)}…` : flat;
145
154
  }
155
+ /**
156
+ * Resolve the repo a project is bound to (CREW-UX-8): the project's first `crew.repo` member,
157
+ * verified against the repo registry so a stale membership (repo deleted after attach) never
158
+ * grounds the task in a path the registry no longer vouches for. `undefined` when the project
159
+ * has no repo member, the registry no longer knows the ref, or the adapter cannot answer (old
160
+ * addon, engine hiccup) — every one of those degrades to today's behavior: an ungrounded launch.
161
+ *
162
+ * WHY: a doc created under a repo-backed project used to launch its governed draft/revision
163
+ * run with NO repo context at all, so the worker could not read the project's actual code and
164
+ * generated placeholder content (operator report, wicked-studio project). Shared by the draft
165
+ * and chat seams.
166
+ *
167
+ * WHY the result feeds a SNAPSHOT, never a binding and never a direct read path (the v4
168
+ * design): the launch itself stays UNBOUND (no `repoRef`), even though the project verifiably
169
+ * has one, because on a repoRef-bound run the worker's ACP tool-permission stream closes on
170
+ * the FIRST call that needs a permission prompt, so the session dies before any work lands —
171
+ * no write destination works, not the external inbox, not an in-repo path (wicked-core#293;
172
+ * v2 of this seam tried both and the adversarial verifier killed each with run evidence).
173
+ * v3 then handed the unbound worker the absolute `rootPath` to READ — but that rested on a
174
+ * boundary-context-dependent premise: the "repo reads work" evidence came from BOUND runs,
175
+ * and an UNBOUND worker's governance boundary is {sandbox, extraWriteRoots,
176
+ * ~/.claude/plugins}, so its reads of the live repo root are governance-DENIED
177
+ * (wicked-core#294). What an unbound worker can always read is the inbox the run already
178
+ * writes to (write roots are readable, wicked-core#259) — so v4 grounds via a capped,
179
+ * launch-scoped repo SNAPSHOT cloned into the inbox crew-side BEFORE the launch (see
180
+ * repo-snapshot.ts), and the grounding clause names the snapshot. `rootPath` here is the
181
+ * clone SOURCE only; `projectId` still passes on the launch — filing is unaffected.
182
+ */
183
+ export async function resolveProjectRepo(adapter, projectId, log) {
184
+ try {
185
+ const members = await adapter.projectMembers(projectId);
186
+ const repoMember = members.find((m) => m.member_kind === 'crew.repo');
187
+ if (repoMember === undefined)
188
+ return undefined;
189
+ const ref = repoMember.member_ref;
190
+ const repo = (await adapter.listRepos()).find((r) => r.id === ref);
191
+ if (repo === undefined) {
192
+ log?.(`[interactive] project ${projectId} has repo member ${ref} but the registry does not — launching without repo context`);
193
+ return undefined;
194
+ }
195
+ return { repoRef: ref, rootPath: repo.root_path };
196
+ }
197
+ catch (err) {
198
+ log?.(`[interactive] could not resolve project ${projectId}'s repo — launching without repo context: ${err instanceof Error ? err.message : String(err)}`);
199
+ return undefined;
200
+ }
201
+ }
202
+ /** The longest snapshot path the grounding clause will carry. NOT a truncation cap — the
203
+ * clause embeds the path VERBATIM or not at all (see {@link groundablePath}): the snapshot
204
+ * sits at exactly one spelling, so a flattened/truncated path would ground the worker on a
205
+ * directory that does not exist (Copilot, crew#313). The budget exists for the PTY prompt
206
+ * length; a dest over it degrades the launch to ungrounded, honestly narrated. */
207
+ export const SNAPSHOT_PATH_MAX = 300;
208
+ /** `true` when a snapshot path can ride the grounding clause EXACTLY as spelled: single-line
209
+ * (the PTY seat runner refuses embedded newlines — and `oneLine`'s whitespace collapse would
210
+ * respell the path, so it is never applied to paths) and within the prompt budget. */
211
+ export function groundablePath(path) {
212
+ return path.length <= SNAPSHOT_PATH_MAX && !/[\n\r\t]/.test(path);
213
+ }
146
214
  /**
147
215
  * The run's problem statement (the engine scopes it per phase and folds each phase's
148
216
  * instructions on top). Carries everything doc-specific: identity, brief, sources, style, and
149
- * the absolute path the finished HTML must land at.
217
+ * the absolute path the finished HTML must land at. When the doc's project is repo-bound
218
+ * (CREW-UX-8 v4), a SHORT grounding clause names the launch-scoped repo SNAPSHOT inside the
219
+ * inbox for the worker to READ — the run itself launches unbound (wicked-core#293) and cannot
220
+ * read the live repo (wicked-core#294), so the snapshot named by this clause is the whole
221
+ * grounding mechanism, not a nudge on top of an engine binding.
222
+ *
223
+ * `snapshotDir` is embedded VERBATIM — never flattened, never truncated (the snapshot exists
224
+ * at exactly this path; Copilot, crew#313). The caller guards it with {@link groundablePath}
225
+ * BEFORE snapshotting and skips grounding (honest degrade) when the path cannot ride.
150
226
  */
151
- export function draftProblem(doc, outPath) {
227
+ export function draftProblem(doc, outPath, snapshotDir) {
152
228
  const sources = doc.sourcePaths.length > 0
153
229
  ? `Source materials to read: ${doc.sourcePaths.join(', ')}.`
154
230
  : 'There are no source files — the brief alone is the spec.';
155
231
  const brief = doc.brief.length > 0 ? oneLine(doc.brief, 2000) : '(no brief provided)';
232
+ const grounding = snapshotDir !== undefined
233
+ ? `Ground the document in the repository snapshot at ${snapshotDir} — read it and use its real content, never placeholders. `
234
+ : '';
156
235
  return (`Produce the first draft of the wicked-interactive document "${doc.documentId}" ` +
157
- `(requested style: ${doc.style}). The user's brief: ${brief} ${sources} ` +
236
+ `(requested style: ${doc.style}). The user's brief: ${brief} ${sources} ${grounding}` +
158
237
  `The finished draft MUST be written to exactly this absolute file path: ${outPath}`);
159
238
  }
160
239
  /** Deterministic bus idempotency key for the one draft this seam may land per document. */
@@ -218,8 +297,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
218
297
  const ledger = new InteractiveHandoffLedger(opts.ledgerPath ?? join(defaultStateDir(), 'interactive-draft-ledger.json'));
219
298
  const draftDir = opts.draftDir ?? join(defaultStateDir(), 'interactive-drafts');
220
299
  const heartbeatMs = opts.heartbeatMs ?? 15_000;
221
- const phaseCount = INTERACTIVE_DRAFT_WORKFLOW_DEF.phases.length;
222
- const inFlight = new Map(); // runId → live state
300
+ // The run executes ONE MORE unit than the def declares: the crew#311 deliverable floor,
301
+ // appended per-run by `launchRun` from `requireDeliverables`. `agentPhaseCount` is what the
302
+ // council-and-worker narration branches key on (unchanged); `phaseCount` is the run's real
303
+ // length, so the thread never reports "phase 3/2" or calls the verification "the draft".
304
+ const agentPhaseCount = INTERACTIVE_DRAFT_WORKFLOW_DEF.phases.length;
305
+ const phaseCount = agentPhaseCount + 1;
306
+ const inFlight = new Map(); // runId → live state (pre-launch placeholders included)
307
+ let closed = false; // set by stop(): a handler mid-snapshot must never launch after shutdown
223
308
  /** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
224
309
  * caller: narration/announce failures are logged — a lost status line must not kill the
225
310
  * subscription, and a duplicate draft emit (WB-002) is the idempotency key WORKING. */
@@ -256,11 +341,28 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
256
341
  function endFlight(runId) {
257
342
  const flight = inFlight.get(runId);
258
343
  if (flight) {
259
- clearInterval(flight.heartbeat);
344
+ if (flight.heartbeat !== undefined)
345
+ clearInterval(flight.heartbeat);
260
346
  inFlight.delete(runId);
261
347
  }
262
348
  return flight;
263
349
  }
350
+ /** CREW-UX-8 v4: the repo snapshot is launch-scoped — remove it on EVERY terminal path
351
+ * (success, no-file, emit-failure, run failure/cancel) so the inbox never accretes dead
352
+ * clones. Best-effort: a leftover snapshot is a disk-space wart, never a correctness one. */
353
+ function removeSnapshot(flight) {
354
+ const dir = flight.snapshotDir;
355
+ if (dir === undefined)
356
+ return;
357
+ flight.snapshotDir = undefined;
358
+ try {
359
+ rmSync(dir, { recursive: true, force: true });
360
+ log(`[interactive-draft] removed repo snapshot ${dir}`);
361
+ }
362
+ catch (err) {
363
+ log(`[interactive-draft] could not remove repo snapshot ${dir}: ${err instanceof Error ? err.message : String(err)}`);
364
+ }
365
+ }
264
366
  /** Terminal-event fold: turn the governed run's own events into interactive narration, and
265
367
  * close the loop with `draft.completed` when the run lands. */
266
368
  const offCoreEvents = adapter.onEvent((event) => {
@@ -273,17 +375,29 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
273
375
  // Narration ladder (#user-feedback 2026-08-14): the heartbeat repeats the LATEST line, and
274
376
  // the interactive transcript dedups consecutive repeats — so the more the line ADVANCES with
275
377
  // the run's real events, the more the thread reads as progress instead of a stuck echo.
276
- const phaseName = (ord) => INTERACTIVE_DRAFT_WORKFLOW_DEF.phases[ord - 1]?.id ?? `phase ${ord}`;
378
+ const phaseName = (ord) => ord > agentPhaseCount
379
+ ? DELIVERABLE_FLOOR_PHASE_ID
380
+ : (INTERACTIVE_DRAFT_WORKFLOW_DEF.phases[ord - 1]?.id ?? `phase ${ord}`);
381
+ // The crew#311 deliverable floor is a DETERMINISTIC tool phase — no seat, no council. Core
382
+ // still emits the seat-selection events for it (its `cli` is the node interpreter's absolute
383
+ // path), so narrating them verbatim put "Council picked /opt/homebrew/.../node for
384
+ // verify-deliverables…" in the reader's thread. Drop those two lines for the floor ord; the
385
+ // `unitDispatched` line that follows immediately says what the phase actually is.
386
+ const isFloorOrd = (e) => typeof e.ord === 'number' && e.ord > agentPhaseCount;
277
387
  if (event.type === 'councilConvened') {
388
+ if (isFloorOrd(event))
389
+ return;
278
390
  const ord = typeof event.ord === 'number' ? event.ord : 0;
279
391
  const seats = Array.isArray(event.clis) ? event.clis.length : 0;
280
392
  // "0-seat council" reads like a bug — generic phrasing whenever clis is missing or empty
281
393
  // (Copilot, #269).
282
394
  const council = seats > 0 ? `a ${seats}-seat council` : 'a council';
283
- narrate(flight, `Convening ${council} to pick who ${ord >= phaseCount ? 'writes the draft' : 'plans the outline'}…`);
395
+ narrate(flight, `Convening ${council} to pick who ${ord >= agentPhaseCount ? 'writes the draft' : 'plans the outline'}…`);
284
396
  return;
285
397
  }
286
398
  if (event.type === 'unitDistributed') {
399
+ if (isFloorOrd(event))
400
+ return;
287
401
  const ord = typeof event.ord === 'number' ? event.ord : 0;
288
402
  const who = typeof event.cli === 'string' ? event.cli : 'a worker';
289
403
  const pct = typeof event.agreement_pct === 'number' ? ` (${event.agreement_pct}% agreement)` : '';
@@ -293,9 +407,11 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
293
407
  if (event.type === 'unitDispatched') {
294
408
  const ord = typeof event.ord === 'number' ? event.ord : 0;
295
409
  const phase = phaseName(ord);
296
- narrate(flight, ord >= phaseCount
297
- ? `Crew phase ${ord}/${phaseCount}: writing the draft (${phase})…`
298
- : `Crew phase ${ord}/${phaseCount}: ${phase} — planning the document…`);
410
+ narrate(flight, ord > agentPhaseCount
411
+ ? `Crew phase ${ord}/${phaseCount}: checking the draft file was actually written (${phase})…`
412
+ : ord >= agentPhaseCount
413
+ ? `Crew phase ${ord}/${phaseCount}: writing the draft (${phase})…`
414
+ : `Crew phase ${ord}/${phaseCount}: ${phase} — planning the document…`);
299
415
  return;
300
416
  }
301
417
  if (event.type === 'toolInvoked') {
@@ -311,9 +427,11 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
311
427
  }
312
428
  if (event.type === 'gateDecided' && event.allow === true) {
313
429
  const ord = typeof event.ord === 'number' ? event.ord : 0;
314
- narrate(flight, ord >= phaseCount
315
- ? 'Gate approved the draft — landing it now…'
316
- : `Gate approved ${phaseName(ord)} — moving on…`);
430
+ narrate(flight, ord > agentPhaseCount
431
+ ? 'Draft file verified on disk — landing it now…'
432
+ : ord >= agentPhaseCount
433
+ ? 'Gate approved the draft — checking the file landed…'
434
+ : `Gate approved ${phaseName(ord)} — moving on…`);
317
435
  return;
318
436
  }
319
437
  if (event.type === 'acpFallback') {
@@ -326,19 +444,31 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
326
444
  finalize(flight, runId);
327
445
  return;
328
446
  }
447
+ if (event.type === 'stepFailed') {
448
+ // Remember the engine's own reason (crew#311): the terminal status below reads far better
449
+ // as "the draft file was never written" than as "inspect it via the API".
450
+ const detail = typeof event.detail === 'string' ? event.detail.trim() : '';
451
+ if (detail.length > 0)
452
+ flight.failureDetail = detail;
453
+ return;
454
+ }
329
455
  if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
330
456
  endFlight(runId);
457
+ removeSnapshot(flight); // the failure path cleans its snapshot too (CREW-UX-8 v4)
331
458
  ledger.recordFailure(flight.documentId);
459
+ const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
332
460
  emitInteractive(STATUS_POSTED, {
333
461
  document_id: flight.documentId,
334
462
  state: 'error',
335
463
  message: `The crew run answering this document ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
336
- `(run ${runId}). Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
464
+ `(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
337
465
  });
338
466
  log(`[interactive-draft] run ${runId} for doc ${flight.documentId} ended: ${event.type}`);
339
467
  }
340
468
  });
341
469
  function finalize(flight, runId) {
470
+ // The run is terminal — its grounding snapshot is done serving reads, on every branch below.
471
+ removeSnapshot(flight);
342
472
  const { documentId, outPath } = flight;
343
473
  let ok = false;
344
474
  try {
@@ -396,33 +526,192 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
396
526
  if (f.documentId === doc.documentId)
397
527
  return;
398
528
  }
399
- mkdirSync(draftDir, { recursive: true });
400
- const outPath = join(draftDir, `${doc.documentId}-v1.html`);
529
+ // Per-run ISOLATION (Copilot, crew#313): every launch gets its OWN subdirectory holding
530
+ // both the deliverable and (when grounded) the repo snapshot, and declares ONLY that
531
+ // subdirectory as its extra write root below. Declaring the shared draftDir wholesale let
532
+ // any draft worker read/write every other run's deliverable AND every other project's
533
+ // snapshot — cross-project exposure through the governance boundary itself.
534
+ // NOT created here (Copilot round 2): runDir is only mkdir'd after the snapshot helper's
535
+ // containment check has ruled the location safe — see the pre-launch mkdir below.
536
+ const runDir = join(draftDir, doc.documentId);
537
+ const outPath = join(runDir, `${doc.documentId}-v1.html`);
401
538
  const runId = randomUUID();
539
+ // PRE-LAUNCH placeholder (Copilot round 2): the awaits below (repo resolution, snapshot)
540
+ // open a window in which this doc has no `inFlight` entry — so the chat seam's `isDocBusy`
541
+ // saw it idle (double-launch race) and stop()'s sweep could not find a half-made snapshot.
542
+ // Register the flight FIRST; every exit path below (refusal, shutdown, launch failure)
543
+ // must endFlight() it.
544
+ const flight = {
545
+ documentId: doc.documentId,
546
+ outPath,
547
+ narration: 'Crew run launched — working on your draft…',
548
+ };
549
+ inFlight.set(runId, flight);
550
+ // CREW-UX-8 v4: a repo-bound project's doc is grounded in a REPO SNAPSHOT — resolve the
551
+ // binding BEFORE the launch. Unbound docs and repo-less projects resolve to `undefined`
552
+ // and launch exactly as before.
553
+ const repo = doc.projectId !== undefined ? await resolveProjectRepo(adapter, doc.projectId, log) : undefined;
402
554
  emitInteractive(STATUS_POSTED, {
403
555
  document_id: doc.documentId,
404
556
  state: 'processing',
405
557
  message: 'A governed crew picked up your brief — planning the draft…',
406
558
  });
559
+ // The snapshot happens crew-side, AFTER the pickup narration (a big clone must not starve
560
+ // the UI's silence budget) and BEFORE launchRun: <runDir>/repo sits inside the run's OWN
561
+ // declared extra write root, so the unbound worker can read it (write roots are readable,
562
+ // wicked-core#259) even though the live repo root is boundary-denied (wicked-core#294) —
563
+ // and NO OTHER run can (per-run isolation, Copilot crew#313). An unsnapshotable repo (over
564
+ // budget, unreadable, clone+copy failed) degrades HONESTLY: ungrounded launch, a visible
565
+ // per-cause note on the thread, and the full reason in the log.
566
+ let snapshotDir;
567
+ let snapshotDest; // where a snapshot was ATTEMPTED (shutdown cleanup)
568
+ if (repo !== undefined) {
569
+ const dest = join(runDir, 'repo');
570
+ if (!groundablePath(dest)) {
571
+ // The PATH itself cannot ride the grounding clause (too long for the PTY prompt
572
+ // budget, or multi-line) — and a truncated spelling would name a nonexistent dir, so
573
+ // grounding is SKIPPED before any clone happens (Copilot, crew#313).
574
+ emitInteractive(STATUS_POSTED, {
575
+ document_id: doc.documentId,
576
+ state: 'working',
577
+ message: 'snapshot path too long to hand to the worker — drafting without repo grounding',
578
+ });
579
+ log(`[interactive-draft] doc ${doc.documentId}: snapshot dest ${dest} cannot ride the grounding clause — launching ungrounded`);
580
+ }
581
+ else {
582
+ // Track the dest BEFORE the await (Copilot round 2): stop() during the clone must be
583
+ // able to sweep the half-made snapshot through the placeholder flight.
584
+ snapshotDest = dest;
585
+ flight.snapshotDir = dest;
586
+ const snap = await snapshotRepo(repo.rootPath, dest, { maxBytes: opts.repoSnapshotMaxBytes, log });
587
+ if (snap.ok) {
588
+ snapshotDir = dest;
589
+ }
590
+ else {
591
+ flight.snapshotDir = undefined; // nothing landed — snapshotRepo cleans its partials
592
+ if (snap.reason === 'dest-overlap') {
593
+ // FAIL CLOSED (Copilot round 2): the configured draft dir places this run's write
594
+ // root inside the live repository (or the repo is registered at the inbox). An
595
+ // "ungrounded" launch would still hand the unbound worker read/write access to
596
+ // live repo content through `extraWriteRoots: [runDir]` — so the launch is REFUSED
597
+ // outright: no mkdir, no run, no ledger row. The status names the CONFIG problem;
598
+ // the thrown error dead-letters the frame, replayable after the config is fixed.
599
+ endFlight(runId);
600
+ const message = `Crew refused to draft this document: the configured draft directory (${draftDir}) ` +
601
+ `overlaps the project's repository (${repo.rootPath}), so launching would give the ` +
602
+ `worker write access inside the live repo. Point the crew draft directory outside ` +
603
+ `every registered repository, then replay the request.`;
604
+ emitInteractive(STATUS_POSTED, {
605
+ document_id: doc.documentId,
606
+ state: 'error',
607
+ message,
608
+ });
609
+ log(`[interactive-draft] doc ${doc.documentId}: REFUSING launch — draft dir ${draftDir} overlaps repo ${repo.rootPath} (dest-overlap)`);
610
+ throw new Error(message);
611
+ }
612
+ // Per-cause operator message (Copilot, crew#313): "too large" was previously
613
+ // claimed for EVERY failure — a deleted repo is not a large one. (`dest-overlap`
614
+ // is handled above: it refuses the launch instead of degrading.)
615
+ const because = {
616
+ 'too-large': 'repository too large to snapshot',
617
+ 'root-unreadable': 'repository path is missing or unreadable',
618
+ 'dest-unclearable': 'a stale snapshot could not be cleared',
619
+ 'copy-failed': 'repository snapshot failed (clone and copy both errored)',
620
+ };
621
+ emitInteractive(STATUS_POSTED, {
622
+ document_id: doc.documentId,
623
+ state: 'working',
624
+ message: `${because[snap.reason]} — drafting without repo grounding`,
625
+ });
626
+ log(`[interactive-draft] doc ${doc.documentId}: repo ${repo.rootPath} could not be snapshotted (${snap.reason}) — launching ungrounded`);
627
+ }
628
+ }
629
+ }
630
+ // Shutdown gate (Copilot round 2): stop() may have run during the awaits above — its sweep
631
+ // already dropped the placeholder, but a clone can re-materialize files after that rm, and
632
+ // a launch must never start once the subscriber detached from the engine's events.
633
+ if (closed) {
634
+ endFlight(runId);
635
+ flight.snapshotDir = undefined;
636
+ if (snapshotDest !== undefined) {
637
+ try {
638
+ rmSync(snapshotDest, { recursive: true, force: true });
639
+ }
640
+ catch {
641
+ // best-effort — a leftover snapshot is a disk-space wart, never a correctness one
642
+ }
643
+ }
644
+ log(`[interactive-draft] doc ${doc.documentId}: subscriber stopped before launch — abandoned (a replay retries)`);
645
+ return;
646
+ }
647
+ // Containment is settled (any repo overlap refused above) — the run's write root may exist.
648
+ mkdirSync(runDir, { recursive: true });
649
+ // Resolve the project's graph BEFORE the launch, never indexing (a refresh is
650
+ // `wicked-estate index` per member, bounded at 600s EACH — doing that inside a launch turns
651
+ // "start a draft" into an unannounced multi-repo job). Missing or stale degrades to no
652
+ // binding and the run proceeds exactly as before; the graph is a bonus, never a gate.
653
+ //
654
+ // The decision is RECORDED on both outcomes, like the API launch path (`api/routes.ts`).
655
+ // "this draft sees the project" and "this draft sees nothing, because X" are equally facts
656
+ // about what the run could observe, and the second is the one someone needs when a worker
657
+ // reports that a sibling repo does not exist. An unexpected failure degrades the same way,
658
+ // but says so — a silent `catch(() => null)` would make a broken binding indistinguishable
659
+ // from a project that simply has no graph yet.
660
+ let projectGraphBinding = null;
661
+ if (doc.projectId !== undefined) {
662
+ const decision = await resolveProjectGraphBinding(adapter, doc.projectId, undefined).catch((err) => ({
663
+ binding: null,
664
+ reason: `the project graph binding could not be resolved ` +
665
+ `(${err instanceof Error ? err.message : String(err)}). ` +
666
+ `This repo-less run gets no code graph.`,
667
+ }));
668
+ projectGraphBinding = decision.binding;
669
+ log(`run ${runId}: ${decision.reason}`);
670
+ }
407
671
  try {
408
672
  await adapter.launchRun({
409
- problem: draftProblem(doc, outPath),
673
+ problem: draftProblem(doc, outPath, snapshotDir),
410
674
  sessionId: runId,
411
675
  clisJson: opts.clisJson ?? JSON.stringify(rosterOf(adapter)),
412
676
  workflow: INTERACTIVE_DRAFT_WORKFLOW,
413
677
  // A project-bound doc's governed draft is FILED (P7 gate DEFECT-1): the engine attaches
414
678
  // the crew.run membership atomically with the launch, so the run shows up in the
415
- // project's activity feed instead of floating unattributed.
416
- projectId: doc.projectId,
417
- // The task text names `outPath` (inside draftDir) as the deliverable, which sits OUTSIDE
679
+ // project's activity feed instead of floating unattributed. An UNFILED doc (DES-UX-001
680
+ // slice U — created through the default mount with no project field) launches with the
681
+ // key OMITTED: an unfiled governed run (project_id: null on the DTO, CREW-UX-2) — never
682
+ // a fabricated 'default' membership.
683
+ ...(doc.projectId !== undefined ? { projectId: doc.projectId } : {}),
684
+ // A project-filed run sees the PROJECT's graph, like any other (verification
685
+ // found this seam launching filed but unbound). These launches are repo-LESS,
686
+ // which is exactly the case that gets a graph where it previously got none.
687
+ ...(projectGraphBinding !== null ? { projectGraph: projectGraphBinding } : {}),
688
+ // CREW-UX-8: deliberately NO `repoRef`, even when the project has one — a repoRef-bound
689
+ // run's tool-permission stream closes on the first prompt-needing call, so no write
690
+ // destination works (wicked-core#293) — and NO live-repo path in the task either: the
691
+ // unbound boundary denies those reads (wicked-core#294). The grounding clause in
692
+ // `problem` names the in-inbox snapshot instead; this ONE launch shape serves all docs.
693
+ // The task text names `outPath` (inside runDir) as the deliverable, which sits OUTSIDE
418
694
  // the unit's sandbox — on the wrapped-CLI path the boundary denied that exact write and
419
- // failed the run AFTER the draft was produced (crew#263, run eed69dfa). Declare the inbox
420
- // so the engine widens the boundary by exactly this root (validated launch-side,
695
+ // failed the run AFTER the draft was produced (crew#263, run eed69dfa). Declare the
696
+ // run's OWN subdirectory — never the shared draftDir (Copilot, crew#313: the wholesale
697
+ // declaration let one project's worker read another's snapshot and deliverables) — so
698
+ // the engine widens the boundary by exactly this run's inbox (validated launch-side,
421
699
  // wicked-core#259).
422
- extraWriteRoots: [draftDir],
700
+ extraWriteRoots: [runDir],
701
+ // THE DELIVERABLE FLOOR (crew#311): the draft file IS the deliverable, so the run is
702
+ // not done until it exists. Without this the engine's substance floor is the only
703
+ // check on this unbound run, and it passes a worker whose Write was denied as long as
704
+ // ~200 characters of narration came first — the exact reproducer shape. The floor
705
+ // phase FAILS the run naming this path; `finalize` below stays as the belt-and-braces
706
+ // check for a run that never reaches the floor at all.
707
+ requireDeliverables: [outPath],
423
708
  });
424
709
  }
425
710
  catch (err) {
711
+ // A launch that never happened keeps no flight and no snapshot (a replayed frame
712
+ // re-registers and re-snapshots fresh).
713
+ endFlight(runId);
714
+ removeSnapshot(flight);
426
715
  // The 'processing' status is already on the thread — close it out honestly so the
427
716
  // canvas never sits in an in-between state on a launch that went nowhere.
428
717
  const reason = err instanceof Error ? err.message : String(err);
@@ -441,24 +730,26 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
441
730
  // delivery retries. The crash window between launch and this write is the reason the
442
731
  // draft emit ALSO carries a deterministic idempotency key.
443
732
  ledger.recordLaunch(doc.documentId, runId);
444
- opts.onRunFiled?.(runId, doc.projectId);
445
- const flight = {
446
- documentId: doc.documentId,
447
- outPath,
448
- narration: 'Crew run launched — working on your draft…',
449
- heartbeat: setInterval(() => {
450
- // Repeat the last real narration so the ~20s status.requested window is always fed,
451
- // even mid-phase when the engine is quiet.
452
- emitInteractive(STATUS_POSTED, {
453
- document_id: flight.documentId,
454
- state: 'working',
455
- message: flight.narration,
456
- });
457
- }, heartbeatMs),
458
- // Do not keep the daemon alive for narration alone.
459
- };
733
+ if (closed) {
734
+ // stop() ran while the engine was accepting the launch: its sweep already dropped the
735
+ // placeholder and the snapshot, and the engine's workers die with the daemon — the
736
+ // ledger row above is what keeps a post-restart redelivery from double-launching.
737
+ return;
738
+ }
739
+ if (doc.projectId !== undefined)
740
+ opts.onRunFiled?.(runId, doc.projectId);
741
+ // Upgrade the placeholder to a live flight: the heartbeat starts once the run exists.
742
+ flight.heartbeat = setInterval(() => {
743
+ // Repeat the last real narration so the ~20s status.requested window is always fed,
744
+ // even mid-phase when the engine is quiet.
745
+ emitInteractive(STATUS_POSTED, {
746
+ document_id: flight.documentId,
747
+ state: 'working',
748
+ message: flight.narration,
749
+ });
750
+ }, heartbeatMs);
751
+ // Do not keep the daemon alive for narration alone.
460
752
  flight.heartbeat.unref?.();
461
- inFlight.set(runId, flight);
462
753
  log(`[interactive-draft] doc ${doc.documentId} → governed run ${runId} (draft → ${outPath})`);
463
754
  }
464
755
  const sub = bus.subscribe({
@@ -479,10 +770,22 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
479
770
  });
480
771
  return {
481
772
  ledger,
773
+ inFlightDocs: () => [...new Set([...inFlight.values()].map((f) => f.documentId))],
482
774
  stop: async () => {
775
+ closed = true; // a handler mid-snapshot sees this and never launches (Copilot round 2)
483
776
  offCoreEvents();
484
- for (const runId of [...inFlight.keys()])
485
- endFlight(runId);
777
+ for (const runId of [...inFlight.keys()]) {
778
+ const flight = endFlight(runId);
779
+ // Best-effort snapshot sweep (Copilot, crew#313): a graceful shutdown with a run in
780
+ // flight would otherwise strand the clone forever — after restart the ledger's launch
781
+ // row suppresses redelivery, so no later fold ever revisits it. Pre-launch
782
+ // placeholders are in the map too (Copilot round 2), so a snapshot still
783
+ // materializing is swept as well — and the handler's own closed-gate re-sweeps
784
+ // whatever the in-flight clone re-materializes after this rm. The engine's workers
785
+ // die with the daemon, so nothing is still reading the snapshot.
786
+ if (flight !== undefined)
787
+ removeSnapshot(flight);
788
+ }
486
789
  await sub.stop();
487
790
  },
488
791
  };