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.
- package/dist/api/audit.d.ts +70 -0
- package/dist/api/audit.d.ts.map +1 -0
- package/dist/api/audit.js +131 -0
- package/dist/api/audit.js.map +1 -0
- package/dist/api/auth.d.ts +192 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +515 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/gate-cache.d.ts +8 -0
- package/dist/api/gate-cache.d.ts.map +1 -1
- package/dist/api/gate-cache.js +10 -0
- package/dist/api/gate-cache.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 +34 -0
- package/dist/api/open-path.d.ts.map +1 -0
- package/dist/api/open-path.js +101 -0
- package/dist/api/open-path.js.map +1 -0
- 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 +164 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +769 -21
- 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/seat-health.d.ts +55 -0
- package/dist/api/seat-health.d.ts.map +1 -0
- package/dist/api/seat-health.js +273 -0
- package/dist/api/seat-health.js.map +1 -0
- package/dist/api/seat-signin.d.ts +27 -0
- package/dist/api/seat-signin.d.ts.map +1 -0
- package/dist/api/seat-signin.js +143 -0
- package/dist/api/seat-signin.js.map +1 -0
- package/dist/api/server.d.ts +190 -3
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +365 -11
- 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 +171 -12
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -0
- package/dist/cli/mcp.js +119 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/core/adapter.d.ts +87 -10
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +347 -28
- 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 +92 -352
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -4
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +99 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -0
- package/dist/interactive/bridge-pool.js +244 -0
- package/dist/interactive/bridge-pool.js.map +1 -0
- package/dist/interactive/bridge-root.d.ts +36 -0
- package/dist/interactive/bridge-root.d.ts.map +1 -0
- package/dist/interactive/bridge-root.js +48 -0
- package/dist/interactive/bridge-root.js.map +1 -0
- 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 +224 -0
- package/dist/interactive/draft-events.d.ts.map +1 -0
- package/dist/interactive/draft-events.js +793 -0
- package/dist/interactive/draft-events.js.map +1 -0
- package/dist/interactive/edit-events.d.ts +194 -0
- package/dist/interactive/edit-events.d.ts.map +1 -0
- package/dist/interactive/edit-events.js +601 -0
- package/dist/interactive/edit-events.js.map +1 -0
- package/dist/interactive/ledger.d.ts +39 -0
- package/dist/interactive/ledger.d.ts.map +1 -0
- package/dist/interactive/ledger.js +93 -0
- package/dist/interactive/ledger.js.map +1 -0
- package/dist/interactive/proxy-routes.d.ts +39 -0
- package/dist/interactive/proxy-routes.d.ts.map +1 -0
- package/dist/interactive/proxy-routes.js +189 -0
- package/dist/interactive/proxy-routes.js.map +1 -0
- 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/interactive/ws-relay.d.ts +85 -0
- package/dist/interactive/ws-relay.d.ts.map +1 -0
- package/dist/interactive/ws-relay.js +191 -0
- package/dist/interactive/ws-relay.js.map +1 -0
- package/dist/projects/activity.d.ts +29 -0
- package/dist/projects/activity.d.ts.map +1 -0
- package/dist/projects/activity.js +172 -0
- package/dist/projects/activity.js.map +1 -0
- package/dist/projects/charter.d.ts +28 -0
- package/dist/projects/charter.d.ts.map +1 -0
- package/dist/projects/charter.js +53 -0
- package/dist/projects/charter.js.map +1 -0
- package/dist/projects/events.d.ts +55 -0
- package/dist/projects/events.d.ts.map +1 -0
- package/dist/projects/events.js +141 -0
- package/dist/projects/events.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/membership-index.d.ts +25 -0
- package/dist/projects/membership-index.d.ts.map +1 -0
- package/dist/projects/membership-index.js +46 -0
- package/dist/projects/membership-index.js.map +1 -0
- package/dist/projects/routes.d.ts +97 -0
- package/dist/projects/routes.d.ts.map +1 -0
- package/dist/projects/routes.js +510 -0
- package/dist/projects/routes.js.map +1 -0
- package/dist/projects/settings.d.ts +32 -0
- package/dist/projects/settings.d.ts.map +1 -0
- package/dist/projects/settings.js +64 -0
- package/dist/projects/settings.js.map +1 -0
- package/dist/qe/acceptance.d.ts +137 -0
- package/dist/qe/acceptance.d.ts.map +1 -0
- package/dist/qe/acceptance.js +249 -0
- package/dist/qe/acceptance.js.map +1 -0
- package/dist/qe/gate-events.d.ts +111 -0
- package/dist/qe/gate-events.d.ts.map +1 -0
- package/dist/qe/gate-events.js +168 -0
- package/dist/qe/gate-events.js.map +1 -0
- package/dist/qe/ledger.d.ts +100 -0
- package/dist/qe/ledger.d.ts.map +1 -0
- package/dist/qe/ledger.js +154 -0
- package/dist/qe/ledger.js.map +1 -0
- 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 +10 -4
- package/dist/studio/assets/index-DaaUU8Ep.css +0 -32
- 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
|