wicked-crew 0.7.8 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/api/audit.d.ts +9 -1
  2. package/dist/api/audit.d.ts.map +1 -1
  3. package/dist/api/audit.js +11 -3
  4. package/dist/api/audit.js.map +1 -1
  5. package/dist/api/delivery-index.d.ts +94 -7
  6. package/dist/api/delivery-index.d.ts.map +1 -1
  7. package/dist/api/delivery-index.js +182 -4
  8. package/dist/api/delivery-index.js.map +1 -1
  9. package/dist/api/elicitation-cache.d.ts +10 -0
  10. package/dist/api/elicitation-cache.d.ts.map +1 -1
  11. package/dist/api/elicitation-cache.js +18 -0
  12. package/dist/api/elicitation-cache.js.map +1 -1
  13. package/dist/api/governance-steering.d.ts +8 -0
  14. package/dist/api/governance-steering.d.ts.map +1 -1
  15. package/dist/api/governance-steering.js +36 -12
  16. package/dist/api/governance-steering.js.map +1 -1
  17. package/dist/api/post-hoc-deliver.d.ts +38 -0
  18. package/dist/api/post-hoc-deliver.d.ts.map +1 -0
  19. package/dist/api/post-hoc-deliver.js +57 -0
  20. package/dist/api/post-hoc-deliver.js.map +1 -0
  21. package/dist/api/routes.d.ts +34 -7
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +314 -35
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/seat-signin.d.ts +18 -2
  26. package/dist/api/seat-signin.d.ts.map +1 -1
  27. package/dist/api/seat-signin.js +21 -2
  28. package/dist/api/seat-signin.js.map +1 -1
  29. package/dist/api/server.d.ts +22 -7
  30. package/dist/api/server.d.ts.map +1 -1
  31. package/dist/api/server.js +131 -22
  32. package/dist/api/server.js.map +1 -1
  33. package/dist/api/stall-watchdog.d.ts +46 -21
  34. package/dist/api/stall-watchdog.d.ts.map +1 -1
  35. package/dist/api/stall-watchdog.js +193 -19
  36. package/dist/api/stall-watchdog.js.map +1 -1
  37. package/dist/api/steering-landing.d.ts +130 -0
  38. package/dist/api/steering-landing.d.ts.map +1 -0
  39. package/dist/api/steering-landing.js +367 -0
  40. package/dist/api/steering-landing.js.map +1 -0
  41. package/dist/api/testing.d.ts +3 -0
  42. package/dist/api/testing.d.ts.map +1 -1
  43. package/dist/api/testing.js +146 -18
  44. package/dist/api/testing.js.map +1 -1
  45. package/dist/api/unit-output.d.ts.map +1 -1
  46. package/dist/api/unit-output.js +7 -1
  47. package/dist/api/unit-output.js.map +1 -1
  48. package/dist/campaigns/plan.d.ts +46 -0
  49. package/dist/campaigns/plan.d.ts.map +1 -1
  50. package/dist/campaigns/plan.js +80 -0
  51. package/dist/campaigns/plan.js.map +1 -1
  52. package/dist/campaigns/routes.d.ts +2 -2
  53. package/dist/campaigns/worktrees.d.ts +39 -0
  54. package/dist/campaigns/worktrees.d.ts.map +1 -0
  55. package/dist/campaigns/worktrees.js +95 -0
  56. package/dist/campaigns/worktrees.js.map +1 -0
  57. package/dist/cli/index.js +46 -13
  58. package/dist/cli/index.js.map +1 -1
  59. package/dist/core/adapter.d.ts +65 -13
  60. package/dist/core/adapter.d.ts.map +1 -1
  61. package/dist/core/adapter.js +148 -31
  62. package/dist/core/adapter.js.map +1 -1
  63. package/dist/core/bridge-reaper.d.ts +54 -9
  64. package/dist/core/bridge-reaper.d.ts.map +1 -1
  65. package/dist/core/bridge-reaper.js +116 -11
  66. package/dist/core/bridge-reaper.js.map +1 -1
  67. package/dist/core/types.d.ts +25 -12
  68. package/dist/core/types.d.ts.map +1 -1
  69. package/dist/core/types.js +14 -0
  70. package/dist/core/types.js.map +1 -1
  71. package/dist/interactive/chat-events.d.ts.map +1 -1
  72. package/dist/interactive/chat-events.js +5 -2
  73. package/dist/interactive/chat-events.js.map +1 -1
  74. package/dist/interactive/demo-events.d.ts.map +1 -1
  75. package/dist/interactive/demo-events.js +5 -2
  76. package/dist/interactive/demo-events.js.map +1 -1
  77. package/dist/interactive/doc-delete-routes.d.ts +69 -0
  78. package/dist/interactive/doc-delete-routes.d.ts.map +1 -0
  79. package/dist/interactive/doc-delete-routes.js +240 -0
  80. package/dist/interactive/doc-delete-routes.js.map +1 -0
  81. package/dist/interactive/doc-ledger-sweep.d.ts +53 -0
  82. package/dist/interactive/doc-ledger-sweep.d.ts.map +1 -0
  83. package/dist/interactive/doc-ledger-sweep.js +45 -0
  84. package/dist/interactive/doc-ledger-sweep.js.map +1 -0
  85. package/dist/interactive/draft-events.d.ts.map +1 -1
  86. package/dist/interactive/draft-events.js +5 -2
  87. package/dist/interactive/draft-events.js.map +1 -1
  88. package/dist/interactive/edit-events.d.ts.map +1 -1
  89. package/dist/interactive/edit-events.js +5 -2
  90. package/dist/interactive/edit-events.js.map +1 -1
  91. package/dist/interactive/ledger.d.ts +20 -0
  92. package/dist/interactive/ledger.d.ts.map +1 -1
  93. package/dist/interactive/ledger.js +32 -0
  94. package/dist/interactive/ledger.js.map +1 -1
  95. package/dist/interactive/ws-relay.d.ts +10 -0
  96. package/dist/interactive/ws-relay.d.ts.map +1 -1
  97. package/dist/interactive/ws-relay.js +16 -0
  98. package/dist/interactive/ws-relay.js.map +1 -1
  99. package/dist/projects/graph-paths.d.ts +7 -22
  100. package/dist/projects/graph-paths.d.ts.map +1 -1
  101. package/dist/projects/graph-paths.js +10 -37
  102. package/dist/projects/graph-paths.js.map +1 -1
  103. package/dist/projects/settings.d.ts +32 -2
  104. package/dist/projects/settings.d.ts.map +1 -1
  105. package/dist/projects/settings.js +48 -6
  106. package/dist/projects/settings.js.map +1 -1
  107. package/dist/projects/state-home.d.ts +42 -0
  108. package/dist/projects/state-home.d.ts.map +1 -0
  109. package/dist/projects/state-home.js +55 -0
  110. package/dist/projects/state-home.js.map +1 -0
  111. package/dist/qe/acceptance.d.ts +9 -0
  112. package/dist/qe/acceptance.d.ts.map +1 -1
  113. package/dist/qe/acceptance.js +21 -1
  114. package/dist/qe/acceptance.js.map +1 -1
  115. package/dist/studio/assets/{index-BdQQv2hz.js → index-gtE6scX0.js} +98 -98
  116. package/dist/studio/index.html +1 -1
  117. package/dist/studio/testid-inventory.json +70 -4
  118. package/endpoint-manifest.json +28 -2
  119. package/package.json +4 -4
@@ -21,17 +21,20 @@ import { registerProjectRoutes } from '../projects/routes.js';
21
21
  import { registerCampaignRoutes } from '../campaigns/routes.js';
22
22
  import { registerGovernanceWikiRoutes } from './governance-wiki.js';
23
23
  import { DEFAULT_STEERING_TYPE, STEERING_TYPE_VALUES, STEERING_TYPES, registerGovernanceSteeringRoutes, } from './governance-steering.js';
24
+ import { isSteeringAuthorRun, landSteeringProposal } from './steering-landing.js';
24
25
  import { registerTestingRoutes } from './testing.js';
25
26
  import { ProjectSettingsStore } from '../projects/settings.js';
26
27
  import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
27
28
  import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
29
+ import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
28
30
  import { MembershipIndex } from '../projects/membership-index.js';
29
31
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
30
32
  import { AuditLog } from './audit.js';
31
33
  import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
32
34
  import { RetryIndex } from './retry-index.js';
33
35
  import { GuidanceIndex } from './guidance-index.js';
34
- import { DeliveryIndex } from './delivery-index.js';
36
+ import { DeliveryIndex, deliveryStateWithVacuity, gitRunBranchIsEmpty, gitWorktreeIsClean, prUrlFrom, } from './delivery-index.js';
37
+ import { runDeliverScript } from './post-hoc-deliver.js';
35
38
  import { LOCAL_ACTOR } from './auth.js';
36
39
  // Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
37
40
  // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
@@ -137,14 +140,18 @@ export const LaunchSchema = z.object({
137
140
  * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
138
141
  projectId: z.string().min(1).optional(),
139
142
  /** crew#293 — `"pr"` appends the hardened deliver Tool phase (push run branch + `gh pr create`)
140
- * to a PER-RUN copy of the selected workflow. Requires `workflow` (enforced by the refine
141
- * below, so the 400 happens at parse time, not after the adapter is consulted). */
142
- deliver: z.literal('pr').optional(),
143
+ * to a PER-RUN copy of the selected workflow; requires `workflow` (enforced by the refine
144
+ * below, so the 400 happens at parse time, not after the adapter is consulted). crew#393 —
145
+ * `"none"` explicitly declines delivery (legal with or without a workflow); OMITTED means the
146
+ * daemon decides: repo-scoped + a CODE-WORK workflow (a def with an `executes_code` phase)
147
+ * defaults to `"pr"` (flippable via the `deliverDefault` setting), everything else to
148
+ * `"none"` — see the resolution below. */
149
+ deliver: z.enum(['pr', 'none']).optional(),
143
150
  /** DES-UX-001 §8.3 (CREW-UX-3) — the run this launch retries. Must name an EXISTING run id
144
151
  * (the route checks the store and 400s with a named error otherwise); persisted via the
145
152
  * `run.launched` audit entry + retry index and echoed as `AgentSession.retry_of`. */
146
153
  retryOf: z.string().min(1).optional(),
147
- }).strict().refine((b) => b.deliver === undefined || b.workflow !== undefined, {
154
+ }).strict().refine((b) => b.deliver !== 'pr' || b.workflow !== undefined, {
148
155
  message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
149
156
  path: ['deliver'],
150
157
  });
@@ -230,14 +237,30 @@ runtime = {}) {
230
237
  const retryIndex = runtime.retryIndex ?? new RetryIndex();
231
238
  const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
232
239
  const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
240
+ const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
241
+ const vacuityProbes = {
242
+ worktreeExists,
243
+ worktreeIsClean: runtime.worktreeIsClean ?? gitWorktreeIsClean(),
244
+ runBranchIsEmpty: runtime.runBranchIsEmpty ??
245
+ gitRunBranchIsEmpty(async (repoRef) => {
246
+ const repos = await adapter.listRepos();
247
+ return repos.find((r) => r.id === repoRef)?.root_path;
248
+ }),
249
+ };
250
+ const deliverExec = runtime.deliverExec ?? runDeliverScript;
233
251
  // The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
234
252
  // record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
235
- // `retry_of` from the lineage index, `guidance` from the guidance index, and `delivery`
236
- // from the delivery index (CREW-UX-8, crew#321), each set only when known (absent, never
237
- // null, spells "not a retry" / "no note" / "delivered nothing").
253
+ // `retry_of` from the lineage index and `guidance` from the guidance index, each set only
254
+ // when known (absent, never null, spells "not a retry" / "no note"). `delivery` (crew#393,
255
+ // api-types 0.18.0) is DERIVED on every served run — delivered from the index (CREW-UX-8's
256
+ // durable record), stranded from the run record + a worktree stat, none otherwise — so a
257
+ // completed code run whose work is sitting unlifted in its worktree is VISIBLE on the wire,
258
+ // legacy records included. The would-be-stranded runs are further split (crew#311): a
259
+ // completed run whose worktree carries NO contribution at all reads `'vacuous'` — units all
260
+ // "done" with nothing produced must be LOUD on the wire, never silently green.
238
261
  // Applied at DTO assembly on exactly the two endpoints that serve the run DTO
239
262
  // (GET /runs + GET /runs/:id); the internal sessionsDetail() consumers are untouched.
240
- const decorateRun = (view) => {
263
+ const decorateRun = async (view) => {
241
264
  view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
242
265
  const retryOf = retryIndex.retryOfFor(view.session.id);
243
266
  if (retryOf !== undefined)
@@ -245,9 +268,10 @@ runtime = {}) {
245
268
  const guidance = guidanceIndex.guidanceFor(view.session.id);
246
269
  if (guidance !== undefined)
247
270
  view.session.guidance = guidance;
248
- const delivery = deliveryIndex.deliveryFor(view.session.id);
249
- if (delivery !== undefined)
250
- view.session.delivery = delivery;
271
+ const state = await deliveryStateWithVacuity(view.session, deliveryIndex.urlFor(view.session.id), vacuityProbes);
272
+ view.session.delivery = state.delivery;
273
+ if (state.deliverUrl !== undefined)
274
+ view.session.deliverUrl = state.deliverUrl;
251
275
  return view;
252
276
  };
253
277
  // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
@@ -643,8 +667,44 @@ runtime = {}) {
643
667
  input.projectGraph = decision.binding;
644
668
  req.log.info({ runId: input.sessionId, projectId: b.projectId, repoRef: b.repoRef ?? null }, `run ${input.sessionId}: ${decision.reason}`);
645
669
  }
646
- if (b.deliver !== undefined)
647
- input.deliver = b.deliver;
670
+ // The delivery contract (crew#393): a completed code run must end with a reviewable
671
+ // deliverable, or an explicit, recorded decision not to. So the deliver option is resolved
672
+ // HERE, at the boundary, for every launch:
673
+ // - explicit 'pr' / 'none' wins (the operator decided);
674
+ // - omitted + repo-scoped + a CODE-WORK workflow (the def carries at least one
675
+ // `executes_code` phase — feature/bug/migration, not chat/onboarding/recon) ⇒ the
676
+ // daemon's `deliverDefault` setting ('pr' unless the operator flipped it) — the default
677
+ // that keeps run 83052f0b's work from stranding invisibly again. The code-work guard is
678
+ // the issue's own scope ("default deliver:'pr' for CODE-WORK launches"): a read-only
679
+ // workflow leaves a clean worktree, and the deliver script FAILS a clean worktree loudly
680
+ // ("nothing to deliver", crew#317) — defaulting it on would flip every repo-scoped chat
681
+ // from completed to failed;
682
+ // - omitted otherwise ⇒ 'none': a repo-less run has no worktree to lift, and a free-text
683
+ // run has no def to append the deliver phase to (the adapter REFUSES deliver:'pr'
684
+ // without a workflow rather than silently dropping it, so defaulting it on would turn
685
+ // a legal launch into a 400).
686
+ // The adapter input spells 'none' as an omitted field; the audit entry below records the
687
+ // RESOLVED value either way, plus whether it was defaulted.
688
+ let deliver;
689
+ let deliverDefaulted = false;
690
+ if (b.deliver !== undefined) {
691
+ deliver = b.deliver;
692
+ }
693
+ else if (b.repoRef !== undefined && b.workflow !== undefined) {
694
+ // Unknown def ⇒ no default (the launch fails at workflow resolution with its own error —
695
+ // defaulting 'pr' onto it would swap that for a misleading deliver-flavored one).
696
+ const def = adapter.getWorkflow(b.workflow);
697
+ const codeWork = def !== null && def.phases.some((p) => p.executes_code === true);
698
+ deliver =
699
+ codeWork && (await adapter.getSettings()).deliverDefault !== 'none' ? 'pr' : 'none';
700
+ deliverDefaulted = true;
701
+ }
702
+ else {
703
+ deliver = 'none';
704
+ deliverDefaulted = true;
705
+ }
706
+ if (deliver === 'pr')
707
+ input.deliver = 'pr';
648
708
  // Retry lineage (DES-UX-001 §8.3): `retryOf` must name an EXISTING run — recording lineage
649
709
  // to a run that never existed would be provenance pointing at nothing, so the launch fails
650
710
  // loudly (400, before anything is committed) rather than filing a dangling edge.
@@ -667,7 +727,10 @@ runtime = {}) {
667
727
  ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
668
728
  ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
669
729
  ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
670
- ...(b.deliver !== undefined ? { deliver: b.deliver } : {}),
730
+ // crew#393: the RESOLVED delivery decision, not just the caller's field — so the
731
+ // trail says what the run will actually do, and whether the daemon decided it.
732
+ deliver,
733
+ ...(deliverDefaulted ? { deliverDefaulted: true } : {}),
671
734
  // CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
672
735
  // restarted daemon's hydrate) reads it back from exactly this entry.
673
736
  ...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
@@ -718,7 +781,7 @@ runtime = {}) {
718
781
  const visible = includeArchived
719
782
  ? views
720
783
  : views.filter((v) => v.session.archived_at == null);
721
- return { runs: sortActionableFirst(visible).map(decorateRun) };
784
+ return { runs: await Promise.all(sortActionableFirst(visible).map(decorateRun)) };
722
785
  });
723
786
  // ── Run archival (crew#265) — write-off, not delete ────────────────────────
724
787
  const ArchiveSchema = z.object({
@@ -778,7 +841,107 @@ runtime = {}) {
778
841
  const run = views.find((v) => v.session.id === id);
779
842
  if (!run)
780
843
  return reply.code(404).send({ error: 'Run not found' });
781
- return { run: decorateRun(run) };
844
+ return { run: await decorateRun(run) };
845
+ });
846
+ // ── Post-hoc delivery (crew#393) — lift a stranded run's worktree into a PR ──
847
+ // The recovery path for the run 83052f0b class: a COMPLETED repo-scoped run whose reviewable
848
+ // work was never lifted (`delivery: 'stranded'` on the wire — including runs recorded long
849
+ // before this route existed). Runs the SAME hardened script as the deliver phase (#293/#317:
850
+ // commit, refuse the default branch, rebase — a conflict aborts LOUDLY with nothing pushed —
851
+ // never force, push, `gh pr create`, success re-derived from a real PR URL) against the run's
852
+ // existing worktree. Idempotent: a delivered run answers its recorded URL, never a second PR.
853
+ // Failure is a loud 4xx/5xx carrying the script's own words — never a silent 200.
854
+ const deliverInFlight = new Set();
855
+ /** The last words of a failed deliver script — enough to name the refusal, bounded so a full
856
+ * git transcript never becomes an error body. */
857
+ const deliverErrorTail = (output) => {
858
+ const trimmed = output.trim();
859
+ return trimmed.length <= 2000 ? trimmed : `…${trimmed.slice(-2000)}`;
860
+ };
861
+ app.post(`${V}/runs/:id/deliver`, {
862
+ config: {
863
+ manifest: {
864
+ responseType: 'DeliverRunResult',
865
+ // 404: unknown run. 409: not completed / repo-less / worktree gone / already in
866
+ // flight / the script's own loud refusal (conflict, nothing to deliver, gh failure).
867
+ // 500: exit 0 with no verifiable PR URL, or the spawn itself failed.
868
+ statusCodes: [200, 404, 409, 500],
869
+ },
870
+ },
871
+ }, async (req, reply) => {
872
+ const { id } = req.params;
873
+ // Idempotency first: a recorded delivery answers the SAME URL — `gh pr create` is never
874
+ // re-run on a delivered run, so a double-click (or a retry after a slow response) cannot
875
+ // double-open. The index only ever holds real runs, so this needs no store round-trip.
876
+ const existing = deliveryIndex.urlFor(id);
877
+ if (existing !== undefined)
878
+ return { prUrl: existing };
879
+ const views = await adapter.sessionsDetail();
880
+ const run = views.find((v) => v.session.id === id);
881
+ if (!run)
882
+ return reply.code(404).send({ error: 'Run not found' });
883
+ const s = run.session;
884
+ if (s.status !== 'completed') {
885
+ return reply.code(409).send({
886
+ error: `run ${id} is ${s.status} — only a completed run can be delivered post-hoc`,
887
+ });
888
+ }
889
+ if (s.repo_ref == null || typeof s.workdir !== 'string' || s.workdir === '') {
890
+ return reply.code(409).send({
891
+ error: `run ${id} is not repo-scoped — there is no worktree to lift into a PR`,
892
+ });
893
+ }
894
+ if (!worktreeExists(s.workdir)) {
895
+ return reply.code(409).send({
896
+ error: `run ${id}'s worktree is gone (${s.workdir}) — nothing left to deliver`,
897
+ });
898
+ }
899
+ // One delivery per run at a time: the script pushes and opens a PR, so two concurrent
900
+ // spawns could race gh into two PRs — the exact double-open idempotency forbids.
901
+ if (deliverInFlight.has(id)) {
902
+ return reply.code(409).send({
903
+ error: `a delivery for run ${id} is already in progress — wait for it to finish`,
904
+ });
905
+ }
906
+ deliverInFlight.add(id);
907
+ let result;
908
+ try {
909
+ result = await deliverExec(s.workdir, s.problem);
910
+ }
911
+ catch (err) {
912
+ return reply.code(500).send({ error: `deliver script could not run: ${message(err)}` });
913
+ }
914
+ finally {
915
+ deliverInFlight.delete(id);
916
+ }
917
+ if (result.spawnFailure === true) {
918
+ // The script never reached its own verdict (spawn failure, timeout kill) — an infra
919
+ // fault, not a refusal: 500 so the caller knows a retry is reasonable.
920
+ return reply.code(500).send({
921
+ error: `deliver script could not run to completion: ${deliverErrorTail(result.output)}`,
922
+ });
923
+ }
924
+ if (result.status !== 0) {
925
+ // The script's own words (crew#317's rule: never silent, never masked) — it names
926
+ // exactly what it refused (rebase conflict, nothing to deliver, gh's error) and
927
+ // guarantees nothing was pushed on the refusing paths.
928
+ return reply.code(409).send({
929
+ error: `deliver failed (exit ${result.status}): ${deliverErrorTail(result.output)}`,
930
+ });
931
+ }
932
+ const url = prUrlFrom(result.output);
933
+ if (url === null) {
934
+ // Exit 0 with no URL should be unreachable (the script re-derives its own success),
935
+ // but a delivery nothing can be pointed at is never recorded (crew#317).
936
+ return reply.code(500).send({
937
+ error: 'deliver script exited 0 but produced no PR URL — refusing to record a delivery nothing can be pointed at',
938
+ });
939
+ }
940
+ // The durable record first, then the read-side index — the same write order as the
941
+ // deliver-phase resolution in server.ts, so the index can only LAG a crash, never lead it.
942
+ audit.record('run.delivered', actorOf(req), { runId: id, detail: { url, via: 'post-hoc' } });
943
+ deliveryIndex.set(id, url);
944
+ return { prUrl: url };
782
945
  });
783
946
  // Durable pre-gate guidance (DES-UX-002 §7.2 — spec'd there as CREW-UX-4, implemented as
784
947
  // CREW-UX-7 because crew#308 already spent that id; see guidance-index.ts). Upserts the ONE
@@ -1042,7 +1205,7 @@ runtime = {}) {
1042
1205
  config: {
1043
1206
  manifest: {
1044
1207
  requestType: 'GateDecision',
1045
- responseType: '{ status: SessionStatus }',
1208
+ responseType: '{ status: SessionStatus; landing?: SteeringLandingResult }',
1046
1209
  // 409 twice over: a run not awaiting a human gate, and an engine refusal at confirm.
1047
1210
  statusCodes: [200, 400, 404, 409],
1048
1211
  },
@@ -1062,6 +1225,15 @@ runtime = {}) {
1062
1225
  .code(409)
1063
1226
  .send({ error: `Run is not awaiting a human gate (status: ${run.session.status})` });
1064
1227
  }
1228
+ // The steering-author landing (crew#388): decided — and the gate prompt captured — BEFORE
1229
+ // the confirm, because a terminal-phase approve prunes the gate cache and moves the run out
1230
+ // of `awaiting_human`. The landing itself runs AFTER a successful approve only.
1231
+ // (`listWorkflows` is presence-guarded for partial-stub adapters — a registry-less adapter
1232
+ // cannot be hosting a steering-author run, and the legacy gate path must not 500 over it.)
1233
+ const steeringPropose = parsed.data.approve &&
1234
+ typeof adapter.listWorkflows === 'function' &&
1235
+ isSteeringAuthorRun(run, adapter.listWorkflows());
1236
+ const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
1065
1237
  try {
1066
1238
  const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
1067
1239
  // WHO approved/rejected — the gate-decision audit (task #88). The engine
@@ -1075,7 +1247,18 @@ runtime = {}) {
1075
1247
  status,
1076
1248
  },
1077
1249
  });
1078
- return reply.send({ status });
1250
+ // APPROVE of the steering-author propose gate = the doctrine's landing moment: the
1251
+ // approved proposal is written to the governance store with `provenance.source: "chat"`,
1252
+ // audited per rule, idempotent on replay, and LOUD on failure — the response carries the
1253
+ // outcome either way, so the studio can show "landed PAT-101" or the explicit error
1254
+ // instead of the silent no-op crew#388 recorded. An approve WITH an amend note still
1255
+ // lands the proposal UNCHANGED: the amend steers the RUN's continuation, not the rule
1256
+ // text (an operator who wants different rules rejects and re-authors). A reject lands
1257
+ // nothing — the run cancels and the proposal stays an artifact.
1258
+ const landing = steeringPropose
1259
+ ? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
1260
+ : undefined;
1261
+ return reply.send({ status, ...(landing !== undefined ? { landing } : {}) });
1079
1262
  }
1080
1263
  catch (err) {
1081
1264
  return reply.code(409).send({ error: message(err) });
@@ -1105,8 +1288,37 @@ runtime = {}) {
1105
1288
  const run = views.find((v) => v.session.id === id);
1106
1289
  if (!run)
1107
1290
  return reply.code(404).send({ error: 'Run not found' });
1291
+ // A TERMINAL run is refused LOUDLY, with the actual recovery named (crew#311 defect 2).
1292
+ // The engine's `resume_run` no-ops on completed/cancelled and answers the status token, so
1293
+ // this route used to reply 200 {"status":"cancelled"} — on the exact runs an operator was
1294
+ // trying to rescue, the recovery affordance read as "resume destroyed my run". A cancelled
1295
+ // run has NO in-run recovery (the path is a retry launch, `POST /runs {retryOf}`); a
1296
+ // completed run has nothing to resume — but a stranded one's work IS liftable post-hoc.
1297
+ // The 409 body carries the machine-readable pointer (`ResumeRefusal` in api-types).
1298
+ const terminal = run.session.status;
1299
+ if (terminal === 'completed' || terminal === 'cancelled') {
1300
+ const state = await deliveryStateWithVacuity(run.session, deliveryIndex.urlFor(id), vacuityProbes);
1301
+ const recovery = terminal === 'completed' && state.delivery === 'stranded'
1302
+ ? 'deliver'
1303
+ : 'retry';
1304
+ const why = terminal === 'cancelled'
1305
+ ? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
1306
+ : state.delivery === 'stranded'
1307
+ ? `run ${id} is already completed — nothing to resume; its unlifted work is in the worktree: deliver it with POST /runs/${id}/deliver`
1308
+ : state.delivery === 'vacuous'
1309
+ ? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
1310
+ : `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
1311
+ return reply.code(409).send({ error: why, recovery });
1312
+ }
1108
1313
  try {
1109
1314
  const gated = run.session.status === 'awaiting_human';
1315
+ // A gated resume IS a gate approval, so it lands a steering-author proposal exactly like
1316
+ // POST /runs/:id/gate would — the "no side door" rule (task #88) holds for the landing
1317
+ // write too (crew#388). Prompt captured pre-confirm; see the gate route.
1318
+ const steeringPropose = gated &&
1319
+ typeof adapter.listWorkflows === 'function' &&
1320
+ isSteeringAuthorRun(run, adapter.listWorkflows());
1321
+ const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
1110
1322
  const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
1111
1323
  // A resume of a gated run IS a gate approval — audit it as one, so the
1112
1324
  // "who approved" trail has no side door (task #88).
@@ -1114,7 +1326,10 @@ runtime = {}) {
1114
1326
  runId: id,
1115
1327
  detail: gated ? { approve: true, via: 'resume', status } : { status },
1116
1328
  });
1117
- return reply.send({ status });
1329
+ const landing = steeringPropose
1330
+ ? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
1331
+ : undefined;
1332
+ return reply.send({ status, ...(landing !== undefined ? { landing } : {}) });
1118
1333
  }
1119
1334
  catch (err) {
1120
1335
  return reply.code(409).send({ error: message(err) });
@@ -1225,11 +1440,13 @@ runtime = {}) {
1225
1440
  if (!ids.includes(id))
1226
1441
  return reply.code(404).send({ error: 'Run not found' });
1227
1442
  // Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
1228
- // The engine writes no elicitation rows yet (its elicitation surface is future work — the
1229
- // resolve path still answers 501), so this read is empty today; it exists so the cache is
1230
- // STRUCTURALLY a latency layer, and the day the engine writes the rows, restart survival
1231
- // holds here exactly as it does for gates, with no route change. Guarded like `runEvents`:
1232
- // a partial-stub adapter (tests) or a pre-0.6.0 addon simply has no durable half.
1443
+ // The engine writes no elicitation rows yet — the LIVE elicitation wire is complete
1444
+ // (create → cache → resolve, crew#357/#358), but the durable half of the PROMPT is still
1445
+ // engine-side future work (wicked-core interaction.rs: "gate today; elicitation reserved") —
1446
+ // so this read is empty today; it exists so the cache is STRUCTURALLY a latency layer, and
1447
+ // the day the engine writes the rows, restart survival holds here exactly as it does for
1448
+ // gates, with no route change. Guarded like `runEvents`: a partial-stub adapter (tests) or
1449
+ // a pre-0.6.0 addon simply has no durable half.
1233
1450
  const durable = typeof adapter.interactionRequests === 'function'
1234
1451
  ? await adapter.interactionRequests(id, 'open')
1235
1452
  : null;
@@ -2048,6 +2265,45 @@ runtime = {}) {
2048
2265
  .send({ error: 'workerStallMinutes must be an integer between 1 and 1440' });
2049
2266
  }
2050
2267
  }
2268
+ // The escalation ladder's knobs (crew#341). This trio lets the PLATFORM touch runs, so a
2269
+ // typo must be a 400, never a silently-dropped key that leaves the operator believing they
2270
+ // armed (or disarmed) automatic recovery. `workerStallEscalateMinutes: 0` is the explicit
2271
+ // OFF spelling — and the shipped default is off (absent).
2272
+ if (Object.hasOwn(patch, 'workerStallEscalateMinutes')) {
2273
+ const mins = patch.workerStallEscalateMinutes;
2274
+ if (typeof mins !== 'number' || !Number.isInteger(mins) || mins < 0 || mins > 1440) {
2275
+ return reply.code(400).send({
2276
+ error: 'workerStallEscalateMinutes must be an integer between 0 (escalation off) and 1440',
2277
+ });
2278
+ }
2279
+ }
2280
+ if (Object.hasOwn(patch, 'workerStallEscalateAction')) {
2281
+ const a = patch.workerStallEscalateAction;
2282
+ if (a !== 'reassign' && a !== 'notify') {
2283
+ return reply
2284
+ .code(400)
2285
+ .send({ error: "workerStallEscalateAction must be 'reassign' or 'notify'" });
2286
+ }
2287
+ }
2288
+ if (Object.hasOwn(patch, 'workerStallMaxEscalations')) {
2289
+ const n = patch.workerStallMaxEscalations;
2290
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 10) {
2291
+ return reply
2292
+ .code(400)
2293
+ .send({ error: 'workerStallMaxEscalations must be an integer between 1 and 10' });
2294
+ }
2295
+ }
2296
+ // deliverDefault (crew#393): the repo-scoped launch delivery default. Two values only —
2297
+ // this knob decides whether completed code runs open PRs, so a typo must be a 400, never
2298
+ // a silently-dropped key that leaves the operator believing they flipped it.
2299
+ if (Object.hasOwn(patch, 'deliverDefault')) {
2300
+ const d = patch.deliverDefault;
2301
+ if (d !== 'pr' && d !== 'none') {
2302
+ return reply
2303
+ .code(400)
2304
+ .send({ error: "deliverDefault must be 'pr' or 'none'" });
2305
+ }
2306
+ }
2051
2307
  // Skin-owned keys (crew#323): allowed through, but VALIDATED rather than trusted. The
2052
2308
  // daemon does not read these values, so the only two things it can check are the two that
2053
2309
  // can hurt it — a value it cannot persist, and a value big enough to bloat settings.json.
@@ -2084,6 +2340,10 @@ runtime = {}) {
2084
2340
  'graphNodeLimit',
2085
2341
  'worker_config_root',
2086
2342
  'workerStallMinutes',
2343
+ 'workerStallEscalateMinutes',
2344
+ 'workerStallEscalateAction',
2345
+ 'workerStallMaxEscalations',
2346
+ 'deliverDefault',
2087
2347
  ];
2088
2348
  const safe = {};
2089
2349
  for (const key of allowed) {
@@ -2153,18 +2413,37 @@ runtime = {}) {
2153
2413
  // ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
2154
2414
  // Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
2155
2415
  // origin, one auth hook, and one CORS posture — the whole point of slice 1.
2416
+ // ONE pool, shared with the governed doc-delete route below: two pools over one root would
2417
+ // race each other into starting duplicate bridges.
2418
+ const interactiveBridges = runtime.interactiveBridges ??
2419
+ new InteractiveBridgePool({
2420
+ log: (m) => app.log.warn(m),
2421
+ debug: (m) => app.log.debug(m),
2422
+ // #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
2423
+ // before `listen`, but only consulted while serving a request, i.e. once bound. The
2424
+ // pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
2425
+ // `GET /` redirects into studio.
2426
+ studioOrigin: () => boundOrigin(app.server.address()),
2427
+ });
2156
2428
  registerInteractiveProxy(app, adapter, {
2157
2429
  settings: projectSettings,
2158
- pool: runtime.interactiveBridges ??
2159
- new InteractiveBridgePool({
2160
- log: (m) => app.log.warn(m),
2161
- debug: (m) => app.log.debug(m),
2162
- // #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
2163
- // before `listen`, but only consulted while serving a request, i.e. once bound. The
2164
- // pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
2165
- // `GET /` redirects into studio.
2166
- studioOrigin: () => boundOrigin(app.server.address()),
2167
- }),
2430
+ pool: interactiveBridges,
2431
+ log: (m) => app.log.warn(m),
2432
+ });
2433
+ // ── Governed doc delete (crew#338) — the one door that changes BOTH stores ──
2434
+ // `DELETE /projects/:id/interactive/docs/:doc` retires the doc on the bridge AND drops crew's
2435
+ // handoff-ledger rows for it (the draft leg keys by document id, so a stale row claims the
2436
+ // name forever — studio#119's ghost). One static segment more specific than the proxy's
2437
+ // wildcard, so the proxy stays pure transport for everything else.
2438
+ registerInteractiveDocDelete(app, adapter, {
2439
+ settings: projectSettings,
2440
+ pool: interactiveBridges,
2441
+ audit,
2442
+ actorOf,
2443
+ // The inert default mirrors AuditLog.noop(): a directly-driven route set must never sweep
2444
+ // the operator's real ~/.wicked-crew ledgers. The real sweep always arrives from
2445
+ // `createServer`.
2446
+ dropDocLedgerRows: runtime.dropDocLedgerRows ?? (() => ({ ok: true, removed_keys: [] })),
2168
2447
  log: (m) => app.log.warn(m),
2169
2448
  });
2170
2449
  }