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.
- package/dist/api/audit.d.ts +13 -0
- package/dist/api/audit.d.ts.map +1 -1
- package/dist/api/audit.js +18 -2
- package/dist/api/audit.js.map +1 -1
- package/dist/api/guidance-index.d.ts +39 -0
- package/dist/api/guidance-index.d.ts.map +1 -0
- package/dist/api/guidance-index.js +67 -0
- package/dist/api/guidance-index.js.map +1 -0
- package/dist/api/open-path.d.ts +16 -0
- package/dist/api/open-path.d.ts.map +1 -1
- package/dist/api/open-path.js +22 -0
- package/dist/api/open-path.js.map +1 -1
- package/dist/api/retry-index.d.ts +30 -0
- package/dist/api/retry-index.d.ts.map +1 -0
- package/dist/api/retry-index.js +45 -0
- package/dist/api/retry-index.js.map +1 -0
- package/dist/api/routes.d.ts +53 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +352 -23
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-files.d.ts +63 -0
- package/dist/api/run-files.d.ts.map +1 -0
- package/dist/api/run-files.js +271 -0
- package/dist/api/run-files.js.map +1 -0
- package/dist/api/server.d.ts +79 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +135 -6
- package/dist/api/server.js.map +1 -1
- package/dist/api/stall-watchdog.d.ts +62 -0
- package/dist/api/stall-watchdog.d.ts.map +1 -0
- package/dist/api/stall-watchdog.js +138 -0
- package/dist/api/stall-watchdog.js.map +1 -0
- package/dist/cli/index.js +78 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +24 -10
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +191 -30
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts +134 -0
- package/dist/core/bridge-reaper.d.ts.map +1 -0
- package/dist/core/bridge-reaper.js +286 -0
- package/dist/core/bridge-reaper.js.map +1 -0
- package/dist/core/deliver.d.ts +118 -0
- package/dist/core/deliver.d.ts.map +1 -0
- package/dist/core/deliver.js +241 -0
- package/dist/core/deliver.js.map +1 -0
- package/dist/core/deliverable-floor.d.ts +103 -0
- package/dist/core/deliverable-floor.d.ts.map +1 -0
- package/dist/core/deliverable-floor.js +173 -0
- package/dist/core/deliverable-floor.js.map +1 -0
- package/dist/core/exec.d.ts +2 -0
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/types.d.ts +79 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +28 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +67 -10
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/chat-events.d.ts +207 -0
- package/dist/interactive/chat-events.d.ts.map +1 -0
- package/dist/interactive/chat-events.js +769 -0
- package/dist/interactive/chat-events.js.map +1 -0
- package/dist/interactive/demo-events.d.ts +283 -0
- package/dist/interactive/demo-events.d.ts.map +1 -0
- package/dist/interactive/demo-events.js +889 -0
- package/dist/interactive/demo-events.js.map +1 -0
- package/dist/interactive/draft-events.d.ts +87 -7
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +352 -49
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/edit-events.d.ts +22 -0
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +73 -2
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/repo-snapshot.d.ts +100 -0
- package/dist/interactive/repo-snapshot.d.ts.map +1 -0
- package/dist/interactive/repo-snapshot.js +289 -0
- package/dist/interactive/repo-snapshot.js.map +1 -0
- package/dist/projects/graph-paths.d.ts +92 -0
- package/dist/projects/graph-paths.d.ts.map +1 -0
- package/dist/projects/graph-paths.js +130 -0
- package/dist/projects/graph-paths.js.map +1 -0
- package/dist/projects/graph.d.ts +179 -0
- package/dist/projects/graph.d.ts.map +1 -0
- package/dist/projects/graph.js +775 -0
- package/dist/projects/graph.js.map +1 -0
- package/dist/projects/routes.d.ts +7 -0
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +122 -0
- package/dist/projects/routes.js.map +1 -1
- package/dist/studio/assets/index-8p8uwCxG.js +530 -0
- package/dist/studio/assets/index-D6S9zUtO.css +32 -0
- package/dist/studio/index.html +5 -3
- package/package.json +3 -3
- package/dist/studio/assets/index-CCwXa1cn.js +0 -428
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 >=
|
|
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
|
|
297
|
-
? `Crew phase ${ord}/${phaseCount}:
|
|
298
|
-
:
|
|
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
|
|
315
|
-
? '
|
|
316
|
-
:
|
|
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})
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
417
|
-
//
|
|
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
|
|
420
|
-
//
|
|
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: [
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
};
|