wicked-crew 0.7.26 → 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 (79) 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/skills/plugin-source.d.ts +149 -32
  60. package/dist/skills/plugin-source.d.ts.map +1 -1
  61. package/dist/skills/plugin-source.js +303 -108
  62. package/dist/skills/plugin-source.js.map +1 -1
  63. package/dist/skills/root-fence.d.ts +2 -2
  64. package/dist/skills/root-fence.js +4 -4
  65. package/dist/skills/root-fence.js.map +1 -1
  66. package/dist/skills/runtime.d.ts +19 -2
  67. package/dist/skills/runtime.d.ts.map +1 -1
  68. package/dist/skills/runtime.js +82 -8
  69. package/dist/skills/runtime.js.map +1 -1
  70. package/dist/skills/store.d.ts +2 -2
  71. package/dist/skills/store.d.ts.map +1 -1
  72. package/dist/skills/store.js +30 -9
  73. package/dist/skills/store.js.map +1 -1
  74. package/dist/studio/assets/index-CdmCMh2f.js +539 -0
  75. package/dist/studio/index.html +1 -1
  76. package/dist/studio/testid-inventory.json +125 -5
  77. package/endpoint-manifest.json +16 -1
  78. package/package.json +3 -3
  79. package/dist/studio/assets/index-FcZg3WDR.js +0 -539
@@ -36,11 +36,13 @@
36
36
  */
37
37
  import { resolveProjectGraphBinding } from '../projects/graph.js';
38
38
  import { mkdirSync, existsSync, rmSync, statSync } from 'node:fs';
39
- import { join } from 'node:path';
39
+ import { basename, dirname, join } from 'node:path';
40
40
  import { randomUUID } from 'node:crypto';
41
41
  import { InteractiveHandoffLedger } from './ledger.js';
42
42
  import { crewStateHome } from '../projects/state-home.js';
43
- import { snapshotRepo } from './repo-snapshot.js';
43
+ import { runDirInsideRepo, snapshotRepo } from './repo-snapshot.js';
44
+ import { resolveInteractiveRoot } from './bridge-root.js';
45
+ import { groundingNarration, resolveGroundingRepos, snapshotDirName, styleContract, } from './doc-grounding.js';
44
46
  import { DELIVERABLE_FLOOR_PHASE_ID } from '../core/deliverable-floor.js';
45
47
  // ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
46
48
  export const INTERACTIVE_DOMAIN = 'wicked-interactive';
@@ -61,6 +63,22 @@ export const INTERACTIVE_PRODUCER = 'wi-crew';
61
63
  * malformed document_id can't name a ledger key or a draft file path. Shared with the
62
64
  * structural-edit seam (edit-events.ts), which guards the same identity. */
63
65
  export const DOC_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
66
+ /**
67
+ * The doc-identity half of EVERY payload crew's interactive seams emit (acceptance finding F-045):
68
+ * `document_id`, plus `project_id` when the document is project-bound. The bridge stamps
69
+ * `project_id` on its own emits (DES-PROJECT-001 enrichment) and the studio files frames by it —
70
+ * so a crew heartbeat carrying `document_id` alone was filed under the Unfiled mount while the
71
+ * project-bound thread heard nothing and, 90 s later, told the user "the generation service may
72
+ * be down" over a run that was executing. Shared by all four seams (draft/edit/chat/demo); an
73
+ * unfiled doc keeps the field OFF — never a fabricated 'default'.
74
+ */
75
+ export function docScope(documentId, projectId) {
76
+ return projectId !== undefined ? { document_id: documentId, project_id: projectId } : { document_id: documentId };
77
+ }
78
+ /** How long a seam waits for the proxy to record a document's create-time grounding binding when
79
+ * a create for the same project is still in flight (doc-grounding.ts `waitFor`): the bridge emits
80
+ * `doc.created` before it answers the create, so the bus can beat the record by a few ms. */
81
+ export const GROUNDING_BINDING_WAIT_MS = 3000;
64
82
  // ── The workflow (workflows-as-data) ─────────────────────────────────────────────────────────
65
83
  export const INTERACTIVE_DRAFT_WORKFLOW = 'interactive-draft';
66
84
  /**
@@ -159,6 +177,12 @@ export function oneLine(text, cap) {
159
177
  * has no repo member, the registry no longer knows the ref, or the adapter cannot answer (old
160
178
  * addon, engine hiccup) — every one of those degrades to today's behavior: an ungrounded launch.
161
179
  *
180
+ * NO LONGER THE SEAM'S GROUNDING RULE (acceptance finding F-046): "the first member" grounded a
181
+ * brochure about wicked-studio on wicked-core. The draft and demo seams now resolve through
182
+ * `doc-grounding.ts` `resolveGroundingRepos` — the repos NAMED on the create request, else the
183
+ * ones the brief names, else the project's sole repo, else none (narrated). Kept exported for
184
+ * diagnostics and its callers.
185
+ *
162
186
  * WHY: a doc created under a repo-backed project used to launch its governed draft/revision
163
187
  * run with NO repo context at all, so the worker could not read the project's actual code and
164
188
  * generated placeholder content (operator report, wicked-studio project). Shared by the draft
@@ -281,24 +305,49 @@ export function proposeClause() {
281
305
  * SINGLE-LINE by contract: the PTY seat runner refuses any embedded newline (FINDING-011), so
282
306
  * every fragment here stays on one line and the brief is flattened+capped by {@link oneLine}.
283
307
  */
284
- export function draftProblem(doc, outPath, snapshotDir, intent) {
308
+ export function draftProblem(doc, outPath, snapshotDir, intent, grounding) {
285
309
  const sources = doc.sourcePaths.length > 0
286
310
  ? `Source materials to read: ${doc.sourcePaths.join(', ')}.`
287
311
  : 'There are no source files — the brief alone is the spec.';
288
312
  const brief = doc.brief.length > 0 ? oneLine(doc.brief, 2000) : '(no brief provided)';
289
- const grounding = `Ground every claim in the indexed repositories via the wicked-estate MCP tools: ` +
313
+ // F-046: the SUBJECT — which repositories the document is about, stated before any tool clause,
314
+ // so the worker never substitutes a sibling repository (the brochure-about-studio-drafted-from-
315
+ // core outcome). Absent for an unfiled doc or a repo-less project.
316
+ const subjects = grounding?.subjects ?? [];
317
+ const subjectClause = subjects.length > 0
318
+ ? `This document is ABOUT the ${subjects.length === 1 ? 'repository' : 'repositories'} ` +
319
+ `${subjects.map((s) => s.name).join(', ')} — ground every product claim in ` +
320
+ `${subjects.length === 1 ? 'that repository' : 'those repositories'} (never a sibling repository ` +
321
+ `that happens to share the project), and say in the draft's notes when something you need is not in reach. `
322
+ : grounding?.unnamedAmong !== undefined && grounding.unnamedAmong > 1
323
+ ? `The project has ${grounding.unnamedAmong} repositories and none was named for this document — do not ` +
324
+ `present details from an arbitrary one as the subject's; where the brief refers to a specific ` +
325
+ `repository, say in the draft's notes that its source was not in reach. `
326
+ : '';
327
+ // The offline fallback: the subjects' snapshots when grounding resolved them (F-046), else the
328
+ // legacy single snapshot dir a caller passed. Paths ride VERBATIM (Copilot, crew#313).
329
+ const snapshots = subjects.flatMap((s) => s.snapshotDir !== undefined ? [{ name: s.name, dir: s.snapshotDir }] : []);
330
+ if (snapshots.length === 0 && snapshotDir !== undefined)
331
+ snapshots.push({ name: undefined, dir: snapshotDir });
332
+ const fallback = snapshots.length === 0
333
+ ? ''
334
+ : snapshots.length === 1
335
+ ? `If the estate tools are unavailable, fall back to the offline repository snapshot at ${snapshots[0].dir}` +
336
+ `${snapshots[0].name !== undefined ? ` (${snapshots[0].name})` : ''} instead. `
337
+ : `If the estate tools are unavailable, fall back to the offline repository snapshots at ` +
338
+ `${snapshots.map((s) => `${s.dir} (${s.name})`).join(' and ')} instead. `;
339
+ const groundingClause = `Ground every claim in the indexed repositories via the wicked-estate MCP tools: ` +
290
340
  `SearchEntity to find relevant code and docs, FetchContent to read them, ContextBundle to gather ` +
291
341
  `related material, and RetrieveEntity/TraverseGraph to follow references — research across all ` +
292
342
  `bound repos and use what those tools return, never placeholders. ` +
293
- (snapshotDir !== undefined
294
- ? `If the estate tools are unavailable, fall back to the offline repository snapshot at ${snapshotDir} instead. `
295
- : '');
343
+ fallback;
296
344
  // The recall clause (DES-MEM-FACETED-001 Phase 3) sits right beside grounding; `''` when the
297
345
  // intent carries no axis, so an unfiled draft reads exactly as it did before this phase.
298
346
  const recall = recallClause(intent);
299
347
  const propose = proposeClause();
300
348
  return (`Produce the first draft of the wicked-interactive document "${doc.documentId}" ` +
301
- `(requested style: ${doc.style}). The user's brief: ${brief} ${sources} ${grounding}${recall}${propose}` +
349
+ `(requested style: ${doc.style} — ${styleContract(doc.style)}). The user's brief: ${brief} ${sources} ` +
350
+ `${subjectClause}${groundingClause}${recall}${propose}` +
302
351
  `The finished draft MUST be written to exactly this absolute file path: ${outPath}`);
303
352
  }
304
353
  /** Deterministic bus idempotency key for the one draft this seam may land per document. */
@@ -365,6 +414,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
365
414
  const ledger = new InteractiveHandoffLedger(opts.ledgerPath ?? join(defaultStateDir(), 'interactive-draft-ledger.json'));
366
415
  const draftDir = opts.draftDir ?? join(defaultStateDir(), 'interactive-drafts');
367
416
  const heartbeatMs = opts.heartbeatMs ?? 15_000;
417
+ const groundingStore = opts.groundingStore;
418
+ const resolveDocsRoot = opts.resolveDocsRoot ?? (() => resolveInteractiveRoot(null));
368
419
  // The run executes ONE MORE unit than the def declares: the crew#311 deliverable floor,
369
420
  // appended per-run by `launchRun` from `requireDeliverables`. `agentPhaseCount` is what the
370
421
  // council-and-worker narration branches key on (unchanged); `phaseCount` is the run's real
@@ -398,10 +449,15 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
398
449
  return false;
399
450
  }
400
451
  }
452
+ /** Every `status.posted` this seam emits is typed as the published frame's payload (codex on
453
+ * crew#506: the wire type at the real boundary, not a detached alias) — `emitInteractive` adds `ts`. */
454
+ function emitStatus(payload) {
455
+ return emitInteractive(STATUS_POSTED, { ...payload });
456
+ }
401
457
  function narrate(flight, message) {
402
458
  flight.narration = message;
403
- emitInteractive(STATUS_POSTED, {
404
- document_id: flight.documentId,
459
+ emitStatus({
460
+ ...docScope(flight.documentId, flight.projectId),
405
461
  state: 'working',
406
462
  message,
407
463
  });
@@ -415,20 +471,34 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
415
471
  }
416
472
  return flight;
417
473
  }
418
- /** CREW-UX-8 v4: the repo snapshot is launch-scoped — remove it on EVERY terminal path
474
+ /** CREW-UX-8 v4: the repo snapshots are launch-scoped — remove them on EVERY terminal path
419
475
  * (success, no-file, emit-failure, run failure/cancel) so the inbox never accretes dead
420
476
  * clones. Best-effort: a leftover snapshot is a disk-space wart, never a correctness one. */
421
- function removeSnapshot(flight) {
422
- const dir = flight.snapshotDir;
423
- if (dir === undefined)
424
- return;
425
- flight.snapshotDir = undefined;
426
- try {
427
- rmSync(dir, { recursive: true, force: true });
428
- log(`[interactive-draft] removed repo snapshot ${dir}`);
477
+ function removeSnapshots(flight) {
478
+ const dirs = flight.snapshotDirs;
479
+ flight.snapshotDirs = [];
480
+ const parents = new Set();
481
+ for (const dir of dirs) {
482
+ try {
483
+ rmSync(dir, { recursive: true, force: true });
484
+ log(`[interactive-draft] removed repo snapshot ${dir}`);
485
+ parents.add(dirname(dir));
486
+ }
487
+ catch (err) {
488
+ log(`[interactive-draft] could not remove repo snapshot ${dir}: ${err instanceof Error ? err.message : String(err)}`);
489
+ }
429
490
  }
430
- catch (err) {
431
- log(`[interactive-draft] could not remove repo snapshot ${dir}: ${err instanceof Error ? err.message : String(err)}`);
491
+ // The per-run `repos/` parent held nothing but this run's snapshots — it goes too, so the
492
+ // inbox never accretes empty shells.
493
+ for (const parent of parents) {
494
+ if (basename(parent) !== 'repos')
495
+ continue;
496
+ try {
497
+ rmSync(parent, { recursive: true, force: true });
498
+ }
499
+ catch {
500
+ /* best-effort */
501
+ }
432
502
  }
433
503
  }
434
504
  /** Terminal-event fold: turn the governed run's own events into interactive narration, and
@@ -522,11 +592,11 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
522
592
  }
523
593
  if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
524
594
  endFlight(runId);
525
- removeSnapshot(flight); // the failure path cleans its snapshot too (CREW-UX-8 v4)
595
+ removeSnapshots(flight); // the failure path cleans its snapshots too (CREW-UX-8 v4)
526
596
  ledger.recordFailure(flight.documentId);
527
597
  const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
528
- emitInteractive(STATUS_POSTED, {
529
- document_id: flight.documentId,
598
+ emitStatus({
599
+ ...docScope(flight.documentId, flight.projectId),
530
600
  state: 'error',
531
601
  message: `The crew run answering this document ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
532
602
  `(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
@@ -535,9 +605,9 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
535
605
  }
536
606
  });
537
607
  function finalize(flight, runId) {
538
- // The run is terminal — its grounding snapshot is done serving reads, on every branch below.
539
- removeSnapshot(flight);
540
- const { documentId, outPath } = flight;
608
+ // The run is terminal — its grounding snapshots are done serving reads, on every branch below.
609
+ removeSnapshots(flight);
610
+ const { documentId, projectId, outPath } = flight;
541
611
  let ok = false;
542
612
  try {
543
613
  ok = existsSync(outPath) && statSync(outPath).size > 0;
@@ -547,8 +617,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
547
617
  }
548
618
  if (!ok) {
549
619
  ledger.recordFailure(documentId);
550
- emitInteractive(STATUS_POSTED, {
551
- document_id: documentId,
620
+ emitStatus({
621
+ ...docScope(documentId, projectId),
552
622
  state: 'error',
553
623
  message: `The crew run completed but produced no draft file at ${outPath} (run ${runId}).`,
554
624
  });
@@ -557,14 +627,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
557
627
  }
558
628
  // ADR-0019 D5: announce by path — the service reads the file itself, so a large draft
559
629
  // never rides the bus payload. The deterministic key makes a re-announce a WB-002 no-op.
560
- const emitted = emitInteractive(DRAFT_COMPLETED, { document_id: documentId, html_path: outPath }, draftIdempotencyKey(documentId));
630
+ const emitted = emitInteractive(DRAFT_COMPLETED, { ...docScope(documentId, projectId), html_path: outPath }, draftIdempotencyKey(documentId));
561
631
  if (!emitted) {
562
632
  // The bus refused the announce (non-WB-002): the draft exists on disk but never reached
563
633
  // the service. Fail HONEST — leaving the ledger row launched-but-never-closed would
564
634
  // silently eat every replay of this doc (the launch gate is `ledger.has`).
565
635
  ledger.recordFailure(documentId);
566
- emitInteractive(STATUS_POSTED, {
567
- document_id: documentId,
636
+ emitStatus({
637
+ ...docScope(documentId, projectId),
568
638
  state: 'error',
569
639
  message: `Crew finished the draft but could not announce it on the bus (run ${runId}); ` +
570
640
  `the draft file is at ${outPath}. Inspect the crew daemon log.`,
@@ -573,8 +643,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
573
643
  return;
574
644
  }
575
645
  ledger.recordEmitted(documentId);
576
- emitInteractive(STATUS_POSTED, {
577
- document_id: documentId,
646
+ emitStatus({
647
+ ...docScope(documentId, projectId),
578
648
  state: 'complete',
579
649
  message: 'First draft is in — landing it on the canvas now. Click any block to refine it.',
580
650
  });
@@ -611,88 +681,138 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
611
681
  // must endFlight() it.
612
682
  const flight = {
613
683
  documentId: doc.documentId,
684
+ projectId: doc.projectId,
614
685
  outPath,
686
+ snapshotDirs: [],
615
687
  narration: 'Crew run launched — working on your draft…',
616
688
  };
617
689
  inFlight.set(runId, flight);
618
- // CREW-UX-8 v4: a repo-bound project's doc is grounded in a REPO SNAPSHOT — resolve the
619
- // binding BEFORE the launch. Unbound docs and repo-less projects resolve to `undefined`
620
- // and launch exactly as before.
621
- const repo = doc.projectId !== undefined ? await resolveProjectRepo(adapter, doc.projectId, log) : undefined;
622
- emitInteractive(STATUS_POSTED, {
623
- document_id: doc.documentId,
690
+ // F-046: WHICH repositories is this document about? The create request's `repo_ref(s)` (the
691
+ // proxy recorded them, keyed by this doc — waited for, bounded, when the create is still
692
+ // answering), else the ones the brief names, else the project's sole repo, else NONE. Never
693
+ // the project's first member: that grounded a brochure about wicked-studio on wicked-core.
694
+ // Unbound docs skip this entirely and launch exactly as before.
695
+ let decision;
696
+ if (doc.projectId !== undefined) {
697
+ // The sidecar sits beside the doc; a refused partition (bridge-root.ts) throws here and the
698
+ // frame goes unanswered — fail closed, like the sibling seams' docs-root reads.
699
+ const binding = groundingStore !== undefined
700
+ ? await groundingStore.waitFor(resolveDocsRoot(doc.projectId), doc.documentId, doc.projectId, GROUNDING_BINDING_WAIT_MS)
701
+ : undefined;
702
+ decision = await resolveGroundingRepos(adapter, doc.projectId, doc.brief, binding?.repo_refs, log);
703
+ }
704
+ emitStatus({
705
+ ...docScope(doc.documentId, doc.projectId),
624
706
  state: 'processing',
625
707
  message: 'A governed crew picked up your brief — planning the draft…',
626
708
  });
627
- // The snapshot happens crew-side, AFTER the pickup narration (a big clone must not starve
628
- // the UI's silence budget) and BEFORE launchRun: <runDir>/repo sits inside the run's OWN
629
- // declared extra write root, so the unbound worker can read it (write roots are readable,
709
+ // The snapshots happen crew-side, AFTER the pickup narration (a big clone must not starve
710
+ // the UI's silence budget) and BEFORE launchRun: <runDir>/repos/<name> sits inside the run's
711
+ // OWN declared extra write root, so the unbound worker can read it (write roots are readable,
630
712
  // wicked-core#259) even though the live repo root is boundary-denied (wicked-core#294) —
631
713
  // and NO OTHER run can (per-run isolation, Copilot crew#313). An unsnapshotable repo (over
632
- // budget, unreadable, clone+copy failed) degrades HONESTLY: ungrounded launch, a visible
633
- // per-cause note on the thread, and the full reason in the log.
634
- let snapshotDir;
635
- let snapshotDest; // where a snapshot was ATTEMPTED (shutdown cleanup)
636
- if (repo !== undefined) {
637
- const dest = join(runDir, 'repo');
714
+ // budget, unreadable, clone+copy failed) degrades HONESTLY: the subject is still NAMED in the
715
+ // task, a visible per-cause note lands on the thread, and the full reason in the log.
716
+ // The inbox must never sit inside ANY registered repository (codex on crew#506): it is the
717
+ // worker's extra write root, so a draft dir configured inside a checkout hands the unbound
718
+ // worker write access to live source whatever the run is about — the per-repo dest-overlap
719
+ // check below covers only the repo being snapshotted. Refuse BEFORE anything is created.
720
+ let inside;
721
+ try {
722
+ inside = await runDirInsideRepo(adapter, runDir);
723
+ }
724
+ catch (err) {
725
+ // Unresolvable (EACCES/ELOOP — plain ENOENT is walked past) = unprovable = refused, WITH a
726
+ // user-facing status naming the cause before the frame dead-letters (Copilot on crew#506).
727
+ endFlight(runId);
728
+ const why = err instanceof Error ? err.message : String(err);
729
+ const message = `Crew refused to draft this document: the configured draft directory (${draftDir}) could not be verified ` +
730
+ `against the registered repositories (${why}). Fix the path or its permissions, then replay the request.`;
731
+ emitStatus({ ...docScope(doc.documentId, doc.projectId), state: 'error', message });
732
+ log(`[interactive-draft] doc ${doc.documentId}: REFUSING launch — run dir ${runDir} unverifiable: ${why}`);
733
+ throw new Error(message);
734
+ }
735
+ if (inside !== null) {
736
+ endFlight(runId);
737
+ const message = `Crew refused to draft this document: the configured draft directory (${draftDir}) overlaps the ` +
738
+ `registered repository ${inside}, so launching would give the worker write access inside live source. ` +
739
+ `Point the crew draft directory outside every registered repository, then replay the request.`;
740
+ emitStatus({ ...docScope(doc.documentId, doc.projectId), state: 'error', message });
741
+ log(`[interactive-draft] doc ${doc.documentId}: REFUSING launch — run dir ${runDir} is inside repo ${inside}`);
742
+ throw new Error(message);
743
+ }
744
+ const snapshotted = [];
745
+ const subjects = [];
746
+ const taken = new Set();
747
+ for (const repo of decision?.repos ?? []) {
748
+ const dest = join(runDir, 'repos', snapshotDirName(repo, taken));
638
749
  if (!groundablePath(dest)) {
639
750
  // The PATH itself cannot ride the grounding clause (too long for the PTY prompt
640
751
  // budget, or multi-line) — and a truncated spelling would name a nonexistent dir, so
641
- // grounding is SKIPPED before any clone happens (Copilot, crew#313).
642
- emitInteractive(STATUS_POSTED, {
643
- document_id: doc.documentId,
752
+ // the snapshot is SKIPPED before any clone happens (Copilot, crew#313).
753
+ emitStatus({
754
+ ...docScope(doc.documentId, doc.projectId),
644
755
  state: 'working',
645
- message: 'snapshot path too long to hand to the worker — drafting without repo grounding',
756
+ message: `snapshot path for ${repo.name} too long to hand to the worker — drafting without its snapshot`,
646
757
  });
647
- log(`[interactive-draft] doc ${doc.documentId}: snapshot dest ${dest} cannot ride the grounding clause — launching ungrounded`);
758
+ log(`[interactive-draft] doc ${doc.documentId}: snapshot dest ${dest} cannot ride the grounding clause — no snapshot for ${repo.repoRef}`);
759
+ subjects.push({ name: repo.name });
760
+ continue;
648
761
  }
649
- else {
650
- // Track the dest BEFORE the await (Copilot round 2): stop() during the clone must be
651
- // able to sweep the half-made snapshot through the placeholder flight.
652
- snapshotDest = dest;
653
- flight.snapshotDir = dest;
654
- const snap = await snapshotRepo(repo.rootPath, dest, { maxBytes: opts.repoSnapshotMaxBytes, log });
655
- if (snap.ok) {
656
- snapshotDir = dest;
657
- }
658
- else {
659
- flight.snapshotDir = undefined; // nothing landed — snapshotRepo cleans its partials
660
- if (snap.reason === 'dest-overlap') {
661
- // FAIL CLOSED (Copilot round 2): the configured draft dir places this run's write
662
- // root inside the live repository (or the repo is registered at the inbox). An
663
- // "ungrounded" launch would still hand the unbound worker read/write access to
664
- // live repo content through `extraWriteRoots: [runDir]` — so the launch is REFUSED
665
- // outright: no mkdir, no run, no ledger row. The status names the CONFIG problem;
666
- // the thrown error dead-letters the frame, replayable after the config is fixed.
667
- endFlight(runId);
668
- const message = `Crew refused to draft this document: the configured draft directory (${draftDir}) ` +
669
- `overlaps the project's repository (${repo.rootPath}), so launching would give the ` +
670
- `worker write access inside the live repo. Point the crew draft directory outside ` +
671
- `every registered repository, then replay the request.`;
672
- emitInteractive(STATUS_POSTED, {
673
- document_id: doc.documentId,
674
- state: 'error',
675
- message,
676
- });
677
- log(`[interactive-draft] doc ${doc.documentId}: REFUSING launch — draft dir ${draftDir} overlaps repo ${repo.rootPath} (dest-overlap)`);
678
- throw new Error(message);
679
- }
680
- // Per-cause operator message (Copilot, crew#313): "too large" was previously
681
- // claimed for EVERY failure — a deleted repo is not a large one. (`dest-overlap`
682
- // is handled above: it refuses the launch instead of degrading.)
683
- const because = {
684
- 'too-large': 'repository too large to snapshot',
685
- 'root-unreadable': 'repository path is missing or unreadable',
686
- 'dest-unclearable': 'a stale snapshot could not be cleared',
687
- 'copy-failed': 'repository snapshot failed (clone and copy both errored)',
688
- };
689
- emitInteractive(STATUS_POSTED, {
690
- document_id: doc.documentId,
691
- state: 'working',
692
- message: `${because[snap.reason]} — drafting without repo grounding`,
693
- });
694
- log(`[interactive-draft] doc ${doc.documentId}: repo ${repo.rootPath} could not be snapshotted (${snap.reason}) — launching ungrounded`);
695
- }
762
+ // Track the dest BEFORE the await (Copilot round 2): stop() during the clone must be able
763
+ // to sweep the half-made snapshot through the placeholder flight.
764
+ flight.snapshotDirs.push(dest);
765
+ const snap = await snapshotRepo(repo.rootPath, dest, { maxBytes: opts.repoSnapshotMaxBytes, log });
766
+ if (snap.ok) {
767
+ snapshotted.push(repo);
768
+ subjects.push({ name: repo.name, snapshotDir: dest });
769
+ continue;
770
+ }
771
+ flight.snapshotDirs = flight.snapshotDirs.filter((d) => d !== dest); // nothing landed — snapshotRepo cleans its partials
772
+ if (snap.reason === 'dest-overlap') {
773
+ // FAIL CLOSED (Copilot round 2): the configured draft dir places this run's write
774
+ // root inside the live repository (or the repo is registered at the inbox). An
775
+ // "ungrounded" launch would still hand the unbound worker read/write access to
776
+ // live repo content through `extraWriteRoots: [runDir]` — so the launch is REFUSED
777
+ // outright: no mkdir, no run, no ledger row. The status names the CONFIG problem;
778
+ // the thrown error dead-letters the frame, replayable after the config is fixed.
779
+ endFlight(runId);
780
+ removeSnapshots(flight); // whatever sibling subjects already landed
781
+ const message = `Crew refused to draft this document: the configured draft directory (${draftDir}) ` +
782
+ `overlaps the project's repository (${repo.rootPath}), so launching would give the ` +
783
+ `worker write access inside the live repo. Point the crew draft directory outside ` +
784
+ `every registered repository, then replay the request.`;
785
+ emitStatus({
786
+ ...docScope(doc.documentId, doc.projectId),
787
+ state: 'error',
788
+ message,
789
+ });
790
+ log(`[interactive-draft] doc ${doc.documentId}: REFUSING launch — draft dir ${draftDir} overlaps repo ${repo.rootPath} (dest-overlap)`);
791
+ throw new Error(message);
792
+ }
793
+ // Per-cause operator message (Copilot, crew#313): "too large" was previously
794
+ // claimed for EVERY failure — a deleted repo is not a large one. (`dest-overlap`
795
+ // is handled above: it refuses the launch instead of degrading.)
796
+ const because = {
797
+ 'too-large': 'repository too large to snapshot',
798
+ 'root-unreadable': 'repository path is missing or unreadable',
799
+ 'dest-unclearable': 'a stale snapshot could not be cleared',
800
+ 'copy-failed': 'repository snapshot failed (clone and copy both errored)',
801
+ };
802
+ emitStatus({
803
+ ...docScope(doc.documentId, doc.projectId),
804
+ state: 'working',
805
+ message: `${because[snap.reason]} (${repo.name}) — drafting without its snapshot`,
806
+ });
807
+ log(`[interactive-draft] doc ${doc.documentId}: repo ${repo.rootPath} could not be snapshotted (${snap.reason}) — no snapshot for ${repo.repoRef}`);
808
+ subjects.push({ name: repo.name });
809
+ }
810
+ // The thread hears WHERE this draft is grounded and WHY (F-046 follow-up) — or, for a
811
+ // multi-repo project whose document named nothing, how to name one next time.
812
+ if (decision !== undefined) {
813
+ const line = groundingNarration(decision, snapshotted, 'draft');
814
+ if (line !== null) {
815
+ emitStatus({ ...docScope(doc.documentId, doc.projectId), state: 'working', message: line });
696
816
  }
697
817
  }
698
818
  // Shutdown gate (Copilot round 2): stop() may have run during the awaits above — its sweep
@@ -700,15 +820,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
700
820
  // a launch must never start once the subscriber detached from the engine's events.
701
821
  if (closed) {
702
822
  endFlight(runId);
703
- flight.snapshotDir = undefined;
704
- if (snapshotDest !== undefined) {
705
- try {
706
- rmSync(snapshotDest, { recursive: true, force: true });
707
- }
708
- catch {
709
- // best-effort — a leftover snapshot is a disk-space wart, never a correctness one
710
- }
711
- }
823
+ removeSnapshots(flight);
712
824
  log(`[interactive-draft] doc ${doc.documentId}: subscriber stopped before launch — abandoned (a replay retries)`);
713
825
  return;
714
826
  }
@@ -742,7 +854,12 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
742
854
  // axis (the reliably-available axis on this seam). An unfiled doc leaves it undefined, so
743
855
  // the clause is omitted. Phase 6: thread cli/repo (no single cli is in scope here — the
744
856
  // launch carries the whole council roster via `clisJson`, not one assigned seat).
745
- problem: draftProblem(doc, outPath, snapshotDir, doc.projectId !== undefined ? { project: doc.projectId } : undefined),
857
+ problem: draftProblem(doc, outPath, undefined, doc.projectId !== undefined ? { project: doc.projectId } : undefined, decision !== undefined
858
+ ? {
859
+ subjects,
860
+ ...(decision.source === 'none' ? { unnamedAmong: decision.memberCount } : {}),
861
+ }
862
+ : undefined),
746
863
  sessionId: runId,
747
864
  clisJson: opts.clisJson ?? JSON.stringify(rosterOf(adapter)),
748
865
  workflow: INTERACTIVE_DRAFT_WORKFLOW,
@@ -783,12 +900,12 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
783
900
  // A launch that never happened keeps no flight and no snapshot (a replayed frame
784
901
  // re-registers and re-snapshots fresh).
785
902
  endFlight(runId);
786
- removeSnapshot(flight);
903
+ removeSnapshots(flight);
787
904
  // The 'processing' status is already on the thread — close it out honestly so the
788
905
  // canvas never sits in an in-between state on a launch that went nowhere.
789
906
  const reason = err instanceof Error ? err.message : String(err);
790
- emitInteractive(STATUS_POSTED, {
791
- document_id: doc.documentId,
907
+ emitStatus({
908
+ ...docScope(doc.documentId, doc.projectId),
792
909
  state: 'error',
793
910
  message: `Crew could not start a run for this document: ${reason}. The assist loop can still take over.`,
794
911
  });
@@ -814,8 +931,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
814
931
  flight.heartbeat = setInterval(() => {
815
932
  // Repeat the last real narration so the ~20s status.requested window is always fed,
816
933
  // even mid-phase when the engine is quiet.
817
- emitInteractive(STATUS_POSTED, {
818
- document_id: flight.documentId,
934
+ emitStatus({
935
+ ...docScope(flight.documentId, flight.projectId),
819
936
  state: 'working',
820
937
  message: flight.narration,
821
938
  });
@@ -856,7 +973,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
856
973
  // whatever the in-flight clone re-materializes after this rm. The engine's workers
857
974
  // die with the daemon, so nothing is still reading the snapshot.
858
975
  if (flight !== undefined)
859
- removeSnapshot(flight);
976
+ removeSnapshots(flight);
860
977
  }
861
978
  await sub.stop();
862
979
  },