wicked-crew 0.5.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 (167) hide show
  1. package/dist/api/audit.d.ts +70 -0
  2. package/dist/api/audit.d.ts.map +1 -0
  3. package/dist/api/audit.js +131 -0
  4. package/dist/api/audit.js.map +1 -0
  5. package/dist/api/auth.d.ts +192 -0
  6. package/dist/api/auth.d.ts.map +1 -0
  7. package/dist/api/auth.js +515 -0
  8. package/dist/api/auth.js.map +1 -0
  9. package/dist/api/gate-cache.d.ts +8 -0
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +10 -0
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/guidance-index.d.ts +39 -0
  14. package/dist/api/guidance-index.d.ts.map +1 -0
  15. package/dist/api/guidance-index.js +67 -0
  16. package/dist/api/guidance-index.js.map +1 -0
  17. package/dist/api/open-path.d.ts +34 -0
  18. package/dist/api/open-path.d.ts.map +1 -0
  19. package/dist/api/open-path.js +101 -0
  20. package/dist/api/open-path.js.map +1 -0
  21. package/dist/api/retry-index.d.ts +30 -0
  22. package/dist/api/retry-index.d.ts.map +1 -0
  23. package/dist/api/retry-index.js +45 -0
  24. package/dist/api/retry-index.js.map +1 -0
  25. package/dist/api/routes.d.ts +164 -1
  26. package/dist/api/routes.d.ts.map +1 -1
  27. package/dist/api/routes.js +769 -21
  28. package/dist/api/routes.js.map +1 -1
  29. package/dist/api/run-files.d.ts +63 -0
  30. package/dist/api/run-files.d.ts.map +1 -0
  31. package/dist/api/run-files.js +271 -0
  32. package/dist/api/run-files.js.map +1 -0
  33. package/dist/api/seat-health.d.ts +55 -0
  34. package/dist/api/seat-health.d.ts.map +1 -0
  35. package/dist/api/seat-health.js +273 -0
  36. package/dist/api/seat-health.js.map +1 -0
  37. package/dist/api/seat-signin.d.ts +27 -0
  38. package/dist/api/seat-signin.d.ts.map +1 -0
  39. package/dist/api/seat-signin.js +143 -0
  40. package/dist/api/seat-signin.js.map +1 -0
  41. package/dist/api/server.d.ts +190 -3
  42. package/dist/api/server.d.ts.map +1 -1
  43. package/dist/api/server.js +365 -11
  44. package/dist/api/server.js.map +1 -1
  45. package/dist/api/stall-watchdog.d.ts +62 -0
  46. package/dist/api/stall-watchdog.d.ts.map +1 -0
  47. package/dist/api/stall-watchdog.js +138 -0
  48. package/dist/api/stall-watchdog.js.map +1 -0
  49. package/dist/cli/index.js +171 -12
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/mcp.d.ts +14 -0
  52. package/dist/cli/mcp.d.ts.map +1 -0
  53. package/dist/cli/mcp.js +119 -0
  54. package/dist/cli/mcp.js.map +1 -0
  55. package/dist/core/adapter.d.ts +87 -10
  56. package/dist/core/adapter.d.ts.map +1 -1
  57. package/dist/core/adapter.js +347 -28
  58. package/dist/core/adapter.js.map +1 -1
  59. package/dist/core/bridge-reaper.d.ts +134 -0
  60. package/dist/core/bridge-reaper.d.ts.map +1 -0
  61. package/dist/core/bridge-reaper.js +286 -0
  62. package/dist/core/bridge-reaper.js.map +1 -0
  63. package/dist/core/deliver.d.ts +118 -0
  64. package/dist/core/deliver.d.ts.map +1 -0
  65. package/dist/core/deliver.js +241 -0
  66. package/dist/core/deliver.js.map +1 -0
  67. package/dist/core/deliverable-floor.d.ts +103 -0
  68. package/dist/core/deliverable-floor.d.ts.map +1 -0
  69. package/dist/core/deliverable-floor.js +173 -0
  70. package/dist/core/deliverable-floor.js.map +1 -0
  71. package/dist/core/exec.d.ts +2 -0
  72. package/dist/core/exec.d.ts.map +1 -1
  73. package/dist/core/exec.js.map +1 -1
  74. package/dist/core/types.d.ts +92 -352
  75. package/dist/core/types.d.ts.map +1 -1
  76. package/dist/core/types.js +14 -4
  77. package/dist/core/types.js.map +1 -1
  78. package/dist/interactive/bridge-pool.d.ts +99 -0
  79. package/dist/interactive/bridge-pool.d.ts.map +1 -0
  80. package/dist/interactive/bridge-pool.js +244 -0
  81. package/dist/interactive/bridge-pool.js.map +1 -0
  82. package/dist/interactive/bridge-root.d.ts +36 -0
  83. package/dist/interactive/bridge-root.d.ts.map +1 -0
  84. package/dist/interactive/bridge-root.js +48 -0
  85. package/dist/interactive/bridge-root.js.map +1 -0
  86. package/dist/interactive/chat-events.d.ts +207 -0
  87. package/dist/interactive/chat-events.d.ts.map +1 -0
  88. package/dist/interactive/chat-events.js +769 -0
  89. package/dist/interactive/chat-events.js.map +1 -0
  90. package/dist/interactive/demo-events.d.ts +283 -0
  91. package/dist/interactive/demo-events.d.ts.map +1 -0
  92. package/dist/interactive/demo-events.js +889 -0
  93. package/dist/interactive/demo-events.js.map +1 -0
  94. package/dist/interactive/draft-events.d.ts +224 -0
  95. package/dist/interactive/draft-events.d.ts.map +1 -0
  96. package/dist/interactive/draft-events.js +793 -0
  97. package/dist/interactive/draft-events.js.map +1 -0
  98. package/dist/interactive/edit-events.d.ts +194 -0
  99. package/dist/interactive/edit-events.d.ts.map +1 -0
  100. package/dist/interactive/edit-events.js +601 -0
  101. package/dist/interactive/edit-events.js.map +1 -0
  102. package/dist/interactive/ledger.d.ts +39 -0
  103. package/dist/interactive/ledger.d.ts.map +1 -0
  104. package/dist/interactive/ledger.js +93 -0
  105. package/dist/interactive/ledger.js.map +1 -0
  106. package/dist/interactive/proxy-routes.d.ts +39 -0
  107. package/dist/interactive/proxy-routes.d.ts.map +1 -0
  108. package/dist/interactive/proxy-routes.js +189 -0
  109. package/dist/interactive/proxy-routes.js.map +1 -0
  110. package/dist/interactive/repo-snapshot.d.ts +100 -0
  111. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  112. package/dist/interactive/repo-snapshot.js +289 -0
  113. package/dist/interactive/repo-snapshot.js.map +1 -0
  114. package/dist/interactive/ws-relay.d.ts +85 -0
  115. package/dist/interactive/ws-relay.d.ts.map +1 -0
  116. package/dist/interactive/ws-relay.js +191 -0
  117. package/dist/interactive/ws-relay.js.map +1 -0
  118. package/dist/projects/activity.d.ts +29 -0
  119. package/dist/projects/activity.d.ts.map +1 -0
  120. package/dist/projects/activity.js +172 -0
  121. package/dist/projects/activity.js.map +1 -0
  122. package/dist/projects/charter.d.ts +28 -0
  123. package/dist/projects/charter.d.ts.map +1 -0
  124. package/dist/projects/charter.js +53 -0
  125. package/dist/projects/charter.js.map +1 -0
  126. package/dist/projects/events.d.ts +55 -0
  127. package/dist/projects/events.d.ts.map +1 -0
  128. package/dist/projects/events.js +141 -0
  129. package/dist/projects/events.js.map +1 -0
  130. package/dist/projects/graph-paths.d.ts +92 -0
  131. package/dist/projects/graph-paths.d.ts.map +1 -0
  132. package/dist/projects/graph-paths.js +130 -0
  133. package/dist/projects/graph-paths.js.map +1 -0
  134. package/dist/projects/graph.d.ts +179 -0
  135. package/dist/projects/graph.d.ts.map +1 -0
  136. package/dist/projects/graph.js +775 -0
  137. package/dist/projects/graph.js.map +1 -0
  138. package/dist/projects/membership-index.d.ts +25 -0
  139. package/dist/projects/membership-index.d.ts.map +1 -0
  140. package/dist/projects/membership-index.js +46 -0
  141. package/dist/projects/membership-index.js.map +1 -0
  142. package/dist/projects/routes.d.ts +97 -0
  143. package/dist/projects/routes.d.ts.map +1 -0
  144. package/dist/projects/routes.js +510 -0
  145. package/dist/projects/routes.js.map +1 -0
  146. package/dist/projects/settings.d.ts +32 -0
  147. package/dist/projects/settings.d.ts.map +1 -0
  148. package/dist/projects/settings.js +64 -0
  149. package/dist/projects/settings.js.map +1 -0
  150. package/dist/qe/acceptance.d.ts +137 -0
  151. package/dist/qe/acceptance.d.ts.map +1 -0
  152. package/dist/qe/acceptance.js +249 -0
  153. package/dist/qe/acceptance.js.map +1 -0
  154. package/dist/qe/gate-events.d.ts +111 -0
  155. package/dist/qe/gate-events.d.ts.map +1 -0
  156. package/dist/qe/gate-events.js +168 -0
  157. package/dist/qe/gate-events.js.map +1 -0
  158. package/dist/qe/ledger.d.ts +100 -0
  159. package/dist/qe/ledger.d.ts.map +1 -0
  160. package/dist/qe/ledger.js +154 -0
  161. package/dist/qe/ledger.js.map +1 -0
  162. package/dist/studio/assets/index-8p8uwCxG.js +530 -0
  163. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  164. package/dist/studio/index.html +5 -3
  165. package/package.json +10 -4
  166. package/dist/studio/assets/index-DaaUU8Ep.css +0 -32
  167. package/dist/studio/assets/index-Fu5DRC00.js +0 -423
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Opt-in governed answering of wicked-interactive's first-draft generation (task #86 spike,
3
+ * Phase 7c first leg).
4
+ *
5
+ * wicked-interactive's service is model-free: when a doc is created with `kind: "source"` it
6
+ * seeds a placeholder v0 and emits `wicked.interactive.doc.created`, expecting *something with
7
+ * intelligence* to answer with `wicked.interactive.draft.completed` carrying the first draft
8
+ * (the service then instruments `data-wid` anchors, themes it, and lands `_v1.html`). Today
9
+ * that answerer is an ad-hoc `assist` agent session. This module makes a crew-governed run the
10
+ * answerer instead — same bus vocabulary, zero interactive-service changes beyond the additive
11
+ * producer row (`wi-crew`) in interactive's events.js ownership table.
12
+ *
13
+ * Shape mirrors `qe/gate-events.ts` (crew's existing bus seam, Phase 6a): OPT-IN, dynamic
14
+ * wicked-bus import, graceful degradation when the bus is absent, durable cursor with
15
+ * `cursor_init: 'latest'` under a dedicated plugin name.
16
+ *
17
+ * Behavioral invariants honored (recon-verified against interactive):
18
+ * - Heartbeat: the canvas shows a working veil and the browser fires ~20s
19
+ * `status.requested` heartbeats; a silent answerer reads as a frozen UI. We narrate
20
+ * `wicked.interactive.status.posted` on every phase transition AND on a ≤15s timer.
21
+ * - Idempotency: the durable cursor redelivers under at-least-once semantics, and a replayed
22
+ * `doc.created` must not produce a duplicate `_v2.html`. A durable per-doc ledger
23
+ * (JSON file, atomic rename) gates the launch, and the final `draft.completed` emit carries
24
+ * a deterministic idempotency key (`crew:interactive.draft:<doc>:v1`) so even a double
25
+ * emit dedupes at the bus (WB-002).
26
+ * - INV-2 (`data-wid`): first drafts are whole documents with no pre-existing anchors — the
27
+ * service instruments fresh ones — so the worker contract explicitly forbids inventing
28
+ * `data-wid` attributes rather than requiring preservation. The feedback→edit leg (fragment
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).
36
+ */
37
+ import { resolveProjectGraphBinding } from '../projects/graph.js';
38
+ import { mkdirSync, existsSync, rmSync, statSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { homedir } from 'node:os';
41
+ import { randomUUID } from 'node:crypto';
42
+ import { InteractiveHandoffLedger } from './ledger.js';
43
+ import { snapshotRepo } from './repo-snapshot.js';
44
+ import { DELIVERABLE_FLOOR_PHASE_ID } from '../core/deliverable-floor.js';
45
+ // ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
46
+ export const INTERACTIVE_DOMAIN = 'wicked-interactive';
47
+ export const DOC_CREATED = 'wicked.interactive.doc.created';
48
+ export const DRAFT_COMPLETED = 'wicked.interactive.draft.completed';
49
+ export const STATUS_POSTED = 'wicked.interactive.status.posted';
50
+ /** Exact-type filter with a domain guard — no wildcard, one event type is the whole trigger. */
51
+ export const INTERACTIVE_BUS_FILTER = `${DOC_CREATED}@${INTERACTIVE_DOMAIN}`;
52
+ /** Dedicated durable-cursor identity — NOT the qe subscriber's `wicked-crew`, so the two
53
+ * seams advance independent cursors and stopping one never strands the other. */
54
+ export const INTERACTIVE_BUS_PLUGIN = 'wicked-crew-interactive-draft';
55
+ /** The producer identity stamped on every event crew's interactive seams emit (this module and
56
+ * edit-events.ts). Must appear in interactive's events.js ownership table for DRAFT_COMPLETED,
57
+ * EDIT_COMPLETED, and STATUS_POSTED — the additive vocabulary rows that are the only
58
+ * interactive changes Phase 7c is allowed. */
59
+ export const INTERACTIVE_PRODUCER = 'wi-crew';
60
+ /** Interactive's doc-name grammar (server.js DOC_NAME) — re-checked before any launch so a
61
+ * malformed document_id can't name a ledger key or a draft file path. Shared with the
62
+ * structural-edit seam (edit-events.ts), which guards the same identity. */
63
+ export const DOC_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
64
+ // ── The workflow (workflows-as-data) ─────────────────────────────────────────────────────────
65
+ export const INTERACTIVE_DRAFT_WORKFLOW = 'interactive-draft';
66
+ /**
67
+ * The governed workflow that produces a first draft. Two agent phases — outline (recon) then
68
+ * draft (build, creator role) — so the run narrates a real phase transition and the drafting
69
+ * worker builds on a planned structure instead of one-shotting the whole document.
70
+ *
71
+ * The phase `instructions` adapt the draft-production contract from interactive's assist skill
72
+ * (Step 5): honor the brief/sources/style, ground content in what the brief supports, produce a
73
+ * complete self-contained HTML document, and NEVER mint `data-wid` attributes (the service
74
+ * instruments fresh anchors itself). They are SINGLE-LINE by contract: the engine folds
75
+ * instructions onto the unit description with a single-line separator, and the PTY seat runner
76
+ * refuses any prompt carrying an embedded newline (wicked-core FINDING-011).
77
+ *
78
+ * All gates are `auto` with `validator_pin: null` — no human gate, no deterministic floor —
79
+ * because the acceptance gate for a draft is the INTERACTIVE side (the service's INV-2
80
+ * instrument+theme pipeline and the user's own eyes on the canvas). Registered via
81
+ * `adapter.registerWorkflow()` at arm time (validate-before-persist, hot-registered into the
82
+ * engine), not added to BUILTIN_WORKFLOWS: this def is crew-only data owned by this seam, not a
83
+ * mirror of a wicked-core drop-in.
84
+ */
85
+ export const INTERACTIVE_DRAFT_WORKFLOW_DEF = {
86
+ id: INTERACTIVE_DRAFT_WORKFLOW,
87
+ is_system: true,
88
+ phases: [
89
+ {
90
+ id: 'outline',
91
+ kind: 'recon',
92
+ instructions: 'Plan the document, do not write it yet: read the brief (and any source files/folders named in the task, expanding ~), then produce a concise outline — the sections in order, the key points each section carries, and the tone/format guidance the draft phase must honor for the requested style (web = rich scrollable page; ppt = fixed landscape slides; brochure = landscape print pages; doc = minimal content-first prose). Never invent facts, numbers, or claims the brief and sources do not support; where material is thin, plan honest placeholder copy that says what belongs there. Output the outline as plain text. Do NOT write HTML and do NOT create any files in this phase.',
93
+ gate_type: 'value',
94
+ gate: 'auto',
95
+ executes_code: false,
96
+ verified_evidence: false,
97
+ required_deliverables: [],
98
+ depends_on: [],
99
+ role: 'neutral',
100
+ skill_ref: null,
101
+ allowed_skills: [],
102
+ validator_pin: null,
103
+ },
104
+ {
105
+ id: 'draft',
106
+ kind: 'build',
107
+ instructions: 'Using the outline from the prior phase, write the COMPLETE first-draft HTML document and SAVE it to the absolute output file named in the task (create parent directories if needed, overwrite if present) — the file on disk is the deliverable, so write it before you finish and end your reply with the absolute path you wrote. Contract: a full self-contained HTML document (inline CSS, no external network resources, no build step); honor the requested style/format and the brief; keep every fact grounded in the brief/sources — never fabricate figures; do NOT add data-wid attributes anywhere (the wicked-interactive service instruments its own anchors); keep the markup semantic and well-formed (balanced tags) so the instrumentation pass lands cleanly.',
108
+ gate_type: 'execution',
109
+ gate: 'auto',
110
+ executes_code: false,
111
+ verified_evidence: false,
112
+ required_deliverables: [],
113
+ depends_on: ['outline'],
114
+ role: 'creator',
115
+ skill_ref: null,
116
+ allowed_skills: [],
117
+ validator_pin: null,
118
+ },
119
+ ],
120
+ };
121
+ /**
122
+ * Parse a bus frame into a {@link SourceDocCreated}, or `null` when it is not an actionable
123
+ * `doc.created` (wrong type, non-`source` kind, missing/malformed document_id). `kind: "demo"`
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).
128
+ */
129
+ export function parseSourceDocCreated(eventType, payload) {
130
+ if (eventType !== DOC_CREATED)
131
+ return null;
132
+ if (typeof payload !== 'object' || payload === null)
133
+ return null;
134
+ const p = payload;
135
+ if (p['kind'] !== 'source')
136
+ return null;
137
+ const documentId = typeof p['document_id'] === 'string' ? p['document_id'] : '';
138
+ if (!DOC_NAME.test(documentId))
139
+ return null;
140
+ const brief = typeof p['brief'] === 'string' ? p['brief'] : '';
141
+ const sourcePaths = Array.isArray(p['source_paths'])
142
+ ? p['source_paths'].filter((s) => typeof s === 'string' && s.length > 0)
143
+ : [];
144
+ const style = typeof p['style'] === 'string' && p['style'].length > 0 ? p['style'] : 'web';
145
+ const projectId = typeof p['project_id'] === 'string' && p['project_id'].length > 0 ? p['project_id'] : undefined;
146
+ return { documentId, brief, sourcePaths, style, ...(projectId !== undefined ? { projectId } : {}) };
147
+ }
148
+ /** Collapse whitespace/newlines to single spaces and cap length — the intent must stay a
149
+ * single line (the PTY seat runner refuses embedded newlines) and a pasted-novel brief must
150
+ * not balloon the worker prompt. Shared with the structural-edit seam. */
151
+ export function oneLine(text, cap) {
152
+ const flat = text.replace(/\s+/g, ' ').trim();
153
+ return flat.length > cap ? `${flat.slice(0, cap)}…` : flat;
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
+ }
214
+ /**
215
+ * The run's problem statement (the engine scopes it per phase and folds each phase's
216
+ * instructions on top). Carries everything doc-specific: identity, brief, sources, style, and
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.
226
+ */
227
+ export function draftProblem(doc, outPath, snapshotDir) {
228
+ const sources = doc.sourcePaths.length > 0
229
+ ? `Source materials to read: ${doc.sourcePaths.join(', ')}.`
230
+ : 'There are no source files — the brief alone is the spec.';
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
+ : '';
235
+ return (`Produce the first draft of the wicked-interactive document "${doc.documentId}" ` +
236
+ `(requested style: ${doc.style}). The user's brief: ${brief} ${sources} ${grounding}` +
237
+ `The finished draft MUST be written to exactly this absolute file path: ${outPath}`);
238
+ }
239
+ /** Deterministic bus idempotency key for the one draft this seam may land per document. */
240
+ export function draftIdempotencyKey(documentId) {
241
+ return `crew:interactive.draft:${documentId}:v1`;
242
+ }
243
+ // ── Durable per-doc ledger (replay-dedup across redelivery AND daemon restarts) ──────────────
244
+ //
245
+ // The ledger implementation now lives in ledger.ts (shared with the structural-edit seam);
246
+ // this leg keys it by DOCUMENT ID — one first draft per document lifetime. Re-exported here so
247
+ // the seam's public surface stays one module.
248
+ export { InteractiveHandoffLedger } from './ledger.js';
249
+ function defaultStateDir() {
250
+ return join(homedir(), '.wicked-crew');
251
+ }
252
+ /** The production council roster, resolved lazily through the adapter's own class so this module
253
+ * never imports the native addon at runtime (unit tests pass `clisJson` and a fake adapter). */
254
+ function rosterOf(adapter) {
255
+ return adapter.constructor.roster();
256
+ }
257
+ /**
258
+ * Arm the seam: register the `interactive-draft` workflow, open a durable
259
+ * `wicked.interactive.doc.created` subscription, and answer each `kind: "source"` creation
260
+ * with a governed run that ends in `wicked.interactive.draft.completed`.
261
+ *
262
+ * Graceful degradation mirrors `startQeGateSubscriber`: a missing wicked-bus package or an
263
+ * unopenable db LOGS and returns `null` — the daemon must still boot on a machine whose bus is
264
+ * broken; interactive's assist loop remains the (always-available) fallback answerer.
265
+ */
266
+ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
267
+ const log = opts.log ?? ((m) => console.error(m));
268
+ let bus;
269
+ try {
270
+ bus = await import('wicked-bus');
271
+ }
272
+ catch (err) {
273
+ log(`[interactive-draft] wicked-bus is not importable — governed drafting disabled: ${err instanceof Error ? err.message : String(err)}`);
274
+ return null;
275
+ }
276
+ let db;
277
+ let config;
278
+ try {
279
+ config = bus.loadConfig(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
280
+ db = bus.openDb(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
281
+ }
282
+ catch (err) {
283
+ log(`[interactive-draft] could not open the bus db${opts.dbPath !== undefined ? ` at ${opts.dbPath}` : ''} — governed drafting disabled: ${err instanceof Error ? err.message : String(err)}`);
284
+ return null;
285
+ }
286
+ // The workflow rides the normal registration path — core validates the def BEFORE it is
287
+ // persisted/hot-registered (FINDING-002 ordering), so a drifted def fails the arm loudly
288
+ // instead of failing the first launch obscurely.
289
+ try {
290
+ await adapter.registerWorkflow(INTERACTIVE_DRAFT_WORKFLOW_DEF);
291
+ }
292
+ catch (err) {
293
+ log(`[interactive-draft] could not register the '${INTERACTIVE_DRAFT_WORKFLOW}' workflow — ` +
294
+ `governed drafting disabled: ${err instanceof Error ? err.message : String(err)}`);
295
+ return null;
296
+ }
297
+ const ledger = new InteractiveHandoffLedger(opts.ledgerPath ?? join(defaultStateDir(), 'interactive-draft-ledger.json'));
298
+ const draftDir = opts.draftDir ?? join(defaultStateDir(), 'interactive-drafts');
299
+ const heartbeatMs = opts.heartbeatMs ?? 15_000;
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
308
+ /** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
309
+ * caller: narration/announce failures are logged — a lost status line must not kill the
310
+ * subscription, and a duplicate draft emit (WB-002) is the idempotency key WORKING. */
311
+ function emitInteractive(type, payload, idempotencyKey) {
312
+ try {
313
+ bus.emit(db, config, {
314
+ event_type: type,
315
+ domain: INTERACTIVE_DOMAIN,
316
+ subdomain: type === DRAFT_COMPLETED ? 'generation' : 'status',
317
+ payload: { ts: new Date().toISOString(), ...payload },
318
+ producer_id: INTERACTIVE_PRODUCER,
319
+ ...(idempotencyKey !== undefined ? { idempotency_key: idempotencyKey } : {}),
320
+ });
321
+ return true;
322
+ }
323
+ catch (err) {
324
+ const code = err.error;
325
+ if (code === 'WB-002') {
326
+ // Duplicate idempotency key — the emit already happened (redelivery race). Success.
327
+ return true;
328
+ }
329
+ log(`[interactive-draft] emit ${type} failed: ${err instanceof Error ? err.message : String(err)}`);
330
+ return false;
331
+ }
332
+ }
333
+ function narrate(flight, message) {
334
+ flight.narration = message;
335
+ emitInteractive(STATUS_POSTED, {
336
+ document_id: flight.documentId,
337
+ state: 'working',
338
+ message,
339
+ });
340
+ }
341
+ function endFlight(runId) {
342
+ const flight = inFlight.get(runId);
343
+ if (flight) {
344
+ if (flight.heartbeat !== undefined)
345
+ clearInterval(flight.heartbeat);
346
+ inFlight.delete(runId);
347
+ }
348
+ return flight;
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
+ }
366
+ /** Terminal-event fold: turn the governed run's own events into interactive narration, and
367
+ * close the loop with `draft.completed` when the run lands. */
368
+ const offCoreEvents = adapter.onEvent((event) => {
369
+ const runId = typeof event.session === 'string' ? event.session : undefined;
370
+ if (runId === undefined)
371
+ return;
372
+ const flight = inFlight.get(runId);
373
+ if (flight === undefined)
374
+ return;
375
+ // Narration ladder (#user-feedback 2026-08-14): the heartbeat repeats the LATEST line, and
376
+ // the interactive transcript dedups consecutive repeats — so the more the line ADVANCES with
377
+ // the run's real events, the more the thread reads as progress instead of a stuck echo.
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;
387
+ if (event.type === 'councilConvened') {
388
+ if (isFloorOrd(event))
389
+ return;
390
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
391
+ const seats = Array.isArray(event.clis) ? event.clis.length : 0;
392
+ // "0-seat council" reads like a bug — generic phrasing whenever clis is missing or empty
393
+ // (Copilot, #269).
394
+ const council = seats > 0 ? `a ${seats}-seat council` : 'a council';
395
+ narrate(flight, `Convening ${council} to pick who ${ord >= agentPhaseCount ? 'writes the draft' : 'plans the outline'}…`);
396
+ return;
397
+ }
398
+ if (event.type === 'unitDistributed') {
399
+ if (isFloorOrd(event))
400
+ return;
401
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
402
+ const who = typeof event.cli === 'string' ? event.cli : 'a worker';
403
+ const pct = typeof event.agreement_pct === 'number' ? ` (${event.agreement_pct}% agreement)` : '';
404
+ narrate(flight, `Council picked ${who} for ${phaseName(ord)}${pct}…`);
405
+ return;
406
+ }
407
+ if (event.type === 'unitDispatched') {
408
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
409
+ const phase = phaseName(ord);
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…`);
415
+ return;
416
+ }
417
+ if (event.type === 'toolInvoked') {
418
+ const tools = Array.isArray(event.tools) ? [...new Set(event.tools)].join(', ') : '';
419
+ if (tools)
420
+ narrate(flight, `Worker is using ${tools} on your document…`);
421
+ return;
422
+ }
423
+ if (event.type === 'unitOutputCaptured') {
424
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
425
+ narrate(flight, `${phaseName(ord)} finished — the governance gate is reviewing it…`);
426
+ return;
427
+ }
428
+ if (event.type === 'gateDecided' && event.allow === true) {
429
+ const ord = typeof event.ord === 'number' ? event.ord : 0;
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…`);
435
+ return;
436
+ }
437
+ if (event.type === 'acpFallback') {
438
+ const who = typeof event.cliKey === 'string' ? event.cliKey : 'the worker';
439
+ narrate(flight, `${who}'s live session dropped — continuing in single-shot mode…`);
440
+ return;
441
+ }
442
+ if (event.type === 'sessionCompleted') {
443
+ endFlight(runId);
444
+ finalize(flight, runId);
445
+ return;
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
+ }
455
+ if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
456
+ endFlight(runId);
457
+ removeSnapshot(flight); // the failure path cleans its snapshot too (CREW-UX-8 v4)
458
+ ledger.recordFailure(flight.documentId);
459
+ const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
460
+ emitInteractive(STATUS_POSTED, {
461
+ document_id: flight.documentId,
462
+ state: 'error',
463
+ message: `The crew run answering this document ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
464
+ `(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
465
+ });
466
+ log(`[interactive-draft] run ${runId} for doc ${flight.documentId} ended: ${event.type}`);
467
+ }
468
+ });
469
+ function finalize(flight, runId) {
470
+ // The run is terminal — its grounding snapshot is done serving reads, on every branch below.
471
+ removeSnapshot(flight);
472
+ const { documentId, outPath } = flight;
473
+ let ok = false;
474
+ try {
475
+ ok = existsSync(outPath) && statSync(outPath).size > 0;
476
+ }
477
+ catch {
478
+ ok = false;
479
+ }
480
+ if (!ok) {
481
+ ledger.recordFailure(documentId);
482
+ emitInteractive(STATUS_POSTED, {
483
+ document_id: documentId,
484
+ state: 'error',
485
+ message: `The crew run completed but produced no draft file at ${outPath} (run ${runId}).`,
486
+ });
487
+ log(`[interactive-draft] run ${runId} completed but ${outPath} is missing/empty`);
488
+ return;
489
+ }
490
+ // ADR-0019 D5: announce by path — the service reads the file itself, so a large draft
491
+ // never rides the bus payload. The deterministic key makes a re-announce a WB-002 no-op.
492
+ const emitted = emitInteractive(DRAFT_COMPLETED, { document_id: documentId, html_path: outPath }, draftIdempotencyKey(documentId));
493
+ if (!emitted) {
494
+ // The bus refused the announce (non-WB-002): the draft exists on disk but never reached
495
+ // the service. Fail HONEST — leaving the ledger row launched-but-never-closed would
496
+ // silently eat every replay of this doc (the launch gate is `ledger.has`).
497
+ ledger.recordFailure(documentId);
498
+ emitInteractive(STATUS_POSTED, {
499
+ document_id: documentId,
500
+ state: 'error',
501
+ message: `Crew finished the draft but could not announce it on the bus (run ${runId}); ` +
502
+ `the draft file is at ${outPath}. Inspect the crew daemon log.`,
503
+ });
504
+ log(`[interactive-draft] draft.completed emit FAILED for doc ${documentId} (run ${runId}) — recorded as failure`);
505
+ return;
506
+ }
507
+ ledger.recordEmitted(documentId);
508
+ emitInteractive(STATUS_POSTED, {
509
+ document_id: documentId,
510
+ state: 'complete',
511
+ message: 'First draft is in — landing it on the canvas now. Click any block to refine it.',
512
+ });
513
+ log(`[interactive-draft] draft.completed emitted for doc ${documentId} (run ${runId})`);
514
+ }
515
+ async function handleDocCreated(event) {
516
+ const doc = parseSourceDocCreated(event.event_type, event.payload);
517
+ if (doc === null)
518
+ return;
519
+ // Replay-dedup: the ledger is the durable gate (redelivery after crash/restart), the
520
+ // in-flight scan the live one (redelivery inside a single process lifetime).
521
+ if (ledger.has(doc.documentId)) {
522
+ log(`[interactive-draft] doc ${doc.documentId} already answered (run ${ledger.get(doc.documentId)?.runId}) — replay ignored`);
523
+ return;
524
+ }
525
+ for (const f of inFlight.values()) {
526
+ if (f.documentId === doc.documentId)
527
+ return;
528
+ }
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`);
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;
554
+ emitInteractive(STATUS_POSTED, {
555
+ document_id: doc.documentId,
556
+ state: 'processing',
557
+ message: 'A governed crew picked up your brief — planning the draft…',
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
+ }
671
+ try {
672
+ await adapter.launchRun({
673
+ problem: draftProblem(doc, outPath, snapshotDir),
674
+ sessionId: runId,
675
+ clisJson: opts.clisJson ?? JSON.stringify(rosterOf(adapter)),
676
+ workflow: INTERACTIVE_DRAFT_WORKFLOW,
677
+ // A project-bound doc's governed draft is FILED (P7 gate DEFECT-1): the engine attaches
678
+ // the crew.run membership atomically with the launch, so the run shows up in the
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
694
+ // the unit's sandbox — on the wrapped-CLI path the boundary denied that exact write and
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,
699
+ // wicked-core#259).
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],
708
+ });
709
+ }
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);
715
+ // The 'processing' status is already on the thread — close it out honestly so the
716
+ // canvas never sits in an in-between state on a launch that went nowhere.
717
+ const reason = err instanceof Error ? err.message : String(err);
718
+ emitInteractive(STATUS_POSTED, {
719
+ document_id: doc.documentId,
720
+ state: 'error',
721
+ message: `Crew could not start a run for this document: ${reason}. The assist loop can still take over.`,
722
+ });
723
+ // DELIBERATELY no ledger write: only an answered doc earns a row, so an operator can
724
+ // replay the dead-lettered doc.created after fixing the daemon and get a real retry.
725
+ // Re-throw so the bus (maxRetries 0) dead-letters the frame — visible, replayable,
726
+ // and incapable of hot-looping.
727
+ throw err;
728
+ }
729
+ // Record AFTER the launch resolved: a failed launch leaves no ledger row, so a replayed
730
+ // delivery retries. The crash window between launch and this write is the reason the
731
+ // draft emit ALSO carries a deterministic idempotency key.
732
+ ledger.recordLaunch(doc.documentId, runId);
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.
752
+ flight.heartbeat.unref?.();
753
+ log(`[interactive-draft] doc ${doc.documentId} → governed run ${runId} (draft → ${outPath})`);
754
+ }
755
+ const sub = bus.subscribe({
756
+ db,
757
+ plugin: INTERACTIVE_BUS_PLUGIN,
758
+ filter: INTERACTIVE_BUS_FILTER,
759
+ // Live triggers only: replaying a bus backlog would answer docs whose drafts the assist
760
+ // loop long since produced. History reconciliation belongs to the state plane, not here.
761
+ cursor_init: 'latest',
762
+ pollIntervalMs: opts.pollIntervalMs ?? 2000,
763
+ // Our own ledger + idempotency key are the dedupe; a bus-level retry of a failed launch
764
+ // would double-launch precisely because the ledger row is only written on success.
765
+ maxRetries: 0,
766
+ handler: (event) => handleDocCreated(event),
767
+ onError: (err, event) => {
768
+ log(`[interactive-draft] handler error on event ${String(event?.event_id ?? '?')}: ${err.message}`);
769
+ },
770
+ });
771
+ return {
772
+ ledger,
773
+ inFlightDocs: () => [...new Set([...inFlight.values()].map((f) => f.documentId))],
774
+ stop: async () => {
775
+ closed = true; // a handler mid-snapshot sees this and never launches (Copilot round 2)
776
+ offCoreEvents();
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
+ }
789
+ await sub.stop();
790
+ },
791
+ };
792
+ }
793
+ //# sourceMappingURL=draft-events.js.map