wicked-crew 0.8.1 → 0.8.3

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 (133) hide show
  1. package/dist/api/ask-paths.d.ts +90 -0
  2. package/dist/api/ask-paths.d.ts.map +1 -0
  3. package/dist/api/ask-paths.js +145 -0
  4. package/dist/api/ask-paths.js.map +1 -0
  5. package/dist/api/ask-relay.d.ts +150 -0
  6. package/dist/api/ask-relay.d.ts.map +1 -0
  7. package/dist/api/ask-relay.js +601 -0
  8. package/dist/api/ask-relay.js.map +1 -0
  9. package/dist/api/auth.d.ts +5 -1
  10. package/dist/api/auth.d.ts.map +1 -1
  11. package/dist/api/auth.js +23 -0
  12. package/dist/api/auth.js.map +1 -1
  13. package/dist/api/chat-scope.d.ts +25 -2
  14. package/dist/api/chat-scope.d.ts.map +1 -1
  15. package/dist/api/chat-scope.js +47 -4
  16. package/dist/api/chat-scope.js.map +1 -1
  17. package/dist/api/diagnostics.d.ts +5 -2
  18. package/dist/api/diagnostics.d.ts.map +1 -1
  19. package/dist/api/diagnostics.js +7 -3
  20. package/dist/api/diagnostics.js.map +1 -1
  21. package/dist/api/eval-sample.d.ts +12 -12
  22. package/dist/api/governance-health.d.ts.map +1 -1
  23. package/dist/api/governance-health.js +9 -6
  24. package/dist/api/governance-health.js.map +1 -1
  25. package/dist/api/mcp.d.ts +10 -10
  26. package/dist/api/mcp.d.ts.map +1 -1
  27. package/dist/api/mcp.js +6 -1
  28. package/dist/api/mcp.js.map +1 -1
  29. package/dist/api/plan-schema.d.ts +14 -0
  30. package/dist/api/plan-schema.d.ts.map +1 -1
  31. package/dist/api/plan-schema.js +4 -0
  32. package/dist/api/plan-schema.js.map +1 -1
  33. package/dist/api/recording.d.ts +30 -1
  34. package/dist/api/recording.d.ts.map +1 -1
  35. package/dist/api/recording.js +54 -6
  36. package/dist/api/recording.js.map +1 -1
  37. package/dist/api/roster-standing.d.ts.map +1 -1
  38. package/dist/api/roster-standing.js +6 -2
  39. package/dist/api/roster-standing.js.map +1 -1
  40. package/dist/api/routes.d.ts +144 -14
  41. package/dist/api/routes.d.ts.map +1 -1
  42. package/dist/api/routes.js +346 -204
  43. package/dist/api/routes.js.map +1 -1
  44. package/dist/api/seat-standing.d.ts +9 -1
  45. package/dist/api/seat-standing.d.ts.map +1 -1
  46. package/dist/api/seat-standing.js +27 -1
  47. package/dist/api/seat-standing.js.map +1 -1
  48. package/dist/api/server.d.ts +34 -0
  49. package/dist/api/server.d.ts.map +1 -1
  50. package/dist/api/server.js +143 -14
  51. package/dist/api/server.js.map +1 -1
  52. package/dist/api/stall-watchdog.d.ts +10 -2
  53. package/dist/api/stall-watchdog.d.ts.map +1 -1
  54. package/dist/api/stall-watchdog.js +32 -13
  55. package/dist/api/stall-watchdog.js.map +1 -1
  56. package/dist/api/testing.d.ts +4 -4
  57. package/dist/campaigns/routes.d.ts +2 -2
  58. package/dist/cli/governance.d.ts +7 -0
  59. package/dist/cli/governance.d.ts.map +1 -1
  60. package/dist/cli/governance.js +14 -1
  61. package/dist/cli/governance.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +36 -7
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/core/adapter.d.ts +1 -0
  66. package/dist/core/adapter.d.ts.map +1 -1
  67. package/dist/core/adapter.js +8 -0
  68. package/dist/core/adapter.js.map +1 -1
  69. package/dist/core/bridge-reaper.d.ts +50 -7
  70. package/dist/core/bridge-reaper.d.ts.map +1 -1
  71. package/dist/core/bridge-reaper.js +242 -42
  72. package/dist/core/bridge-reaper.js.map +1 -1
  73. package/dist/core/governance-store.d.ts +9 -0
  74. package/dist/core/governance-store.d.ts.map +1 -1
  75. package/dist/core/governance-store.js +13 -0
  76. package/dist/core/governance-store.js.map +1 -1
  77. package/dist/core/run-boot-resume.d.ts +55 -0
  78. package/dist/core/run-boot-resume.d.ts.map +1 -0
  79. package/dist/core/run-boot-resume.js +94 -0
  80. package/dist/core/run-boot-resume.js.map +1 -0
  81. package/dist/core/types.d.ts +2 -0
  82. package/dist/core/types.d.ts.map +1 -1
  83. package/dist/core/types.js.map +1 -1
  84. package/dist/decisions/chat-recorder.d.ts +18 -0
  85. package/dist/decisions/chat-recorder.d.ts.map +1 -1
  86. package/dist/decisions/chat-recorder.js +107 -14
  87. package/dist/decisions/chat-recorder.js.map +1 -1
  88. package/dist/decisions/consider.d.ts +13 -0
  89. package/dist/decisions/consider.d.ts.map +1 -1
  90. package/dist/decisions/consider.js +20 -4
  91. package/dist/decisions/consider.js.map +1 -1
  92. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  93. package/dist/interactive/bridge-pool.js +3 -2
  94. package/dist/interactive/bridge-pool.js.map +1 -1
  95. package/dist/interactive/doc-grounding.d.ts +37 -0
  96. package/dist/interactive/doc-grounding.d.ts.map +1 -1
  97. package/dist/interactive/doc-grounding.js +63 -0
  98. package/dist/interactive/doc-grounding.js.map +1 -1
  99. package/dist/interactive/doc-list-routes.d.ts +5 -0
  100. package/dist/interactive/doc-list-routes.d.ts.map +1 -1
  101. package/dist/interactive/doc-list-routes.js +7 -1
  102. package/dist/interactive/doc-list-routes.js.map +1 -1
  103. package/dist/interactive/draft-events.d.ts +32 -2
  104. package/dist/interactive/draft-events.d.ts.map +1 -1
  105. package/dist/interactive/draft-events.js +94 -20
  106. package/dist/interactive/draft-events.js.map +1 -1
  107. package/dist/interactive/draft-skill.d.ts.map +1 -1
  108. package/dist/interactive/draft-skill.js +24 -6
  109. package/dist/interactive/draft-skill.js.map +1 -1
  110. package/dist/interactive/review-events.d.ts +1 -1
  111. package/dist/interactive/review-events.d.ts.map +1 -1
  112. package/dist/interactive/review-events.js +4 -2
  113. package/dist/interactive/review-events.js.map +1 -1
  114. package/dist/skills/live-generations.d.ts +16 -11
  115. package/dist/skills/live-generations.d.ts.map +1 -1
  116. package/dist/skills/live-generations.js +28 -14
  117. package/dist/skills/live-generations.js.map +1 -1
  118. package/dist/skills/store.d.ts +16 -0
  119. package/dist/skills/store.d.ts.map +1 -1
  120. package/dist/skills/store.js +18 -0
  121. package/dist/skills/store.js.map +1 -1
  122. package/dist/studio/assets/index-BKkB4En4.js +581 -0
  123. package/dist/studio/assets/index-CGjbTpLl.css +32 -0
  124. package/dist/studio/editors/wicked-page/editor.json +1 -1
  125. package/dist/studio/editors/wicked-page/index.html +1 -1
  126. package/dist/studio/index.html +2 -2
  127. package/dist/studio/testid-inventory.json +275 -5
  128. package/dist/team/routes.d.ts +38 -0
  129. package/dist/team/routes.d.ts.map +1 -1
  130. package/endpoint-manifest.json +1 -1
  131. package/package.json +4 -4
  132. package/dist/studio/assets/index-D9cD_lY1.js +0 -580
  133. package/dist/studio/assets/index-DEBAWrq3.css +0 -32
@@ -19,10 +19,11 @@ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
19
19
  import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
20
20
  import { SeatHealthTracker } from './seat-health.js';
21
21
  import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
22
- import { chatSeatAdmission, noCredentialCause } from './seat-standing.js';
22
+ import { chatSeatAdmission } from './seat-standing.js';
23
23
  import { rosterWithStandingFactory } from './roster-standing.js';
24
24
  import { ChatTurnIndex } from './chat-turns.js';
25
- import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
25
+ import { ChatScopeIndex, chatScopeDeps, chatScopeStatement, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
26
+ import { AskPathIndex, answerStep } from './ask-paths.js';
26
27
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
27
28
  import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isGitStopped, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
28
29
  import { DocRunIndex } from '../interactive/doc-run-index.js';
@@ -65,7 +66,7 @@ import { resolveLinkedIssues } from '../core/linked-issues.js';
65
66
  import { AuditLog } from './audit.js';
66
67
  import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
67
68
  import { GovernanceDiagnostics } from './governance-health.js';
68
- import { legacyHomeOutboxPath } from '../core/governance-store.js';
69
+ import { governanceStoreFix, legacyHomeOutboxPath } from '../core/governance-store.js';
69
70
  import { UsageError, replayOutboxInto } from '../cli/governance.js';
70
71
  import { foldDeadletters } from './governance-health.js';
71
72
  import { RetryIndex } from './retry-index.js';
@@ -538,6 +539,8 @@ export const ChatOpenSchema = z.object({
538
539
  /** The scope the caller NAMES (studio#323 R4) — omitted ⇒ the legacy inference. Shape rules
539
540
  * (which kinds need / refuse `projectId` / `repoRefs`) are the resolver's (`chat-scope.ts`). */
540
541
  scopeKind: z.enum(['system', 'everything', 'project', 'repo', 'repos', 'none']).optional(),
542
+ /** (ASK-C1) The seat the operator chose as the primary agent; omitted ⇒ the engine picks at random. */
543
+ primary: z.string().min(1).optional(),
541
544
  }).strict();
542
545
  /**
543
546
  * The `singleSeat` disclosure (crew#641, hole closed in crew#650) — built in ONE place so the 201
@@ -606,7 +609,7 @@ at) {
606
609
  refusalsKnown: known,
607
610
  message:
608
611
  // "warm" on a turn: the claim is about the roster as it stands, not about how it got there.
609
- `This chat has one ${at === 'open' ? 'seat' : 'warm seat'} (${warmed}); it cannot disagree with itself. ` +
612
+ `This chat has one eligible seat (${warmed}); it cannot disagree with itself. ` +
610
613
  refusalClause +
611
614
  'The single-seat root cause is tracked as wicked-core#563; this chat makes it visible.',
612
615
  };
@@ -641,6 +644,7 @@ runtime = {}) {
641
644
  const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
642
645
  const chatScopes = runtime.chatScopes ?? new ChatScopeIndex();
643
646
  const chatTurns = runtime.chatTurns ?? new ChatTurnIndex();
647
+ const askPaths = runtime.askPaths ?? new AskPathIndex();
644
648
  const chatTranscripts = runtime.chatTranscripts;
645
649
  const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
646
650
  // Wave 6: the doc↔run binding and the registered test sets — `createServer` injects the real
@@ -962,6 +966,10 @@ runtime = {}) {
962
966
  : { deliverGate: false, revisesPr: false, chatIdOnLaunch: false, seatChipOnCreate: false }),
963
967
  // C1: `AgentSession.chat_id` is served from the daemon's own launch index, whatever the engine.
964
968
  runChatId: true,
969
+ // ASK-C1 (DES-ASK-TEAM-CHAT-001 §5.1): an ask starts a path — the whole contract is the
970
+ // pinned core-ts floor the adapter reports; an older studio reads ABSENT as "every helper
971
+ // answers", a newer one renders the one PA voice.
972
+ askPath: typeof adapter.engineCapabilities === 'function' ? adapter.engineCapabilities().askPath : false,
965
973
  // WT-W1: repo-bound launches mint an evidence root only on an addon that takes the field.
966
974
  walkthroughRoots: typeof adapter.evidenceRootsSupported === 'function' ? adapter.evidenceRootsSupported() : false,
967
975
  };
@@ -1754,7 +1762,8 @@ runtime = {}) {
1754
1762
  // always has at least one warm seat (`crew-api-types` ChatOpenResponse), so 0 is not a value
1755
1763
  // `chat_seat_count` can honestly hold.
1756
1764
  if (b.chatId !== undefined) {
1757
- const promotedFrom = await adapter.chatSeats(b.chatId).catch(() => null);
1765
+ // ASK-C1: a chat's seats are its eligible roster (no warm pool to ask the engine about).
1766
+ const promotedFrom = askPaths.get(b.chatId)?.eligible ?? null;
1758
1767
  const scope = chatScopes.engineOf(b.chatId);
1759
1768
  if (promotedFrom !== null && promotedFrom.length > 0 && scope !== undefined) {
1760
1769
  chatSeatCount = promotedFrom.length;
@@ -2355,9 +2364,11 @@ runtime = {}) {
2355
2364
  if (token === null) {
2356
2365
  const state = chatScopes.stateOf(chatId);
2357
2366
  return reply.code(409).send({
2358
- error: state === 'closing'
2359
- ? `chat ${chatId} is closing; wait for its chatClosed (a few seconds at most) before reusing the id, or omit chatId to mint a fresh one`
2360
- : `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
2367
+ error: state === 'held'
2368
+ ? `chat ${chatId} is closing (its DELETE is still settling); retry in a moment, or omit chatId to mint a fresh one`
2369
+ : state === 'closing'
2370
+ ? `chat ${chatId} is closing; wait for its chatClosed (a few seconds at most) before reusing the id, or omit chatId to mint a fresh one`
2371
+ : `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
2361
2372
  });
2362
2373
  }
2363
2374
  // DC-S7: the in-force rules the statement lists; recorded as "told" only once the open succeeds.
@@ -2391,9 +2402,17 @@ runtime = {}) {
2391
2402
  // DC-S7: the seats learn the project's in-force rules from the statement (cap 20, severity
2392
2403
  // order); the service remembers what they were told so a later landing can be prefaced.
2393
2404
  inForceAtOpen = (await considerations.inForceFor(scope.projectId ?? null)).rules;
2405
+ // The scratch root is shared by id: an open whose reservation a DELETE cancelled while it
2406
+ // was resolving must not write (or later remove) the root a newer open now holds
2407
+ // (codex on #808 r3, 3).
2408
+ if (!chatScopes.owns(chatId, token)) {
2409
+ return reply.code(409).send({ error: `chat ${chatId} was closed while it was being opened; open it again` });
2410
+ }
2394
2411
  prepareChatScratch(chatId, scope, inForceAtOpen);
2395
2412
  }
2396
2413
  catch (err) {
2414
+ // The helper removes only what it created itself (a pre-existing root is preserved) —
2415
+ // nothing more is removed here (codex on #808 r4, 3).
2397
2416
  chatScopes.release(chatId, token);
2398
2417
  return reply
2399
2418
  .code(500)
@@ -2412,135 +2431,80 @@ runtime = {}) {
2412
2431
  // and `system` (studio#323 R4: the platform itself, no repository) read none, so they take the
2413
2432
  // unscoped admission and open on an engine that predates chat scope.
2414
2433
  const scoped = scope.kind !== 'none' && scope.kind !== 'system';
2434
+ // ASK-C2: a single-repo ask launches BOUND (the run holds the repository read-only), so every
2435
+ // seat in standing is eligible; a wider repository scope keeps the structural rule.
2436
+ const admissionKind = scope.kind === 'repos' && scope.repos.length === 1 ? 'path-bound' : 'path';
2415
2437
  const refused = [];
2416
2438
  // The standing roster, read ONCE: the default admission below and the engine-drop
2417
2439
  // attribution after `chatOpen` both consult it (F-A45-011).
2418
2440
  const standing = rosterWithStanding();
2419
- const standingOf = (key) => standing.find((s) => String(s.key) === key);
2420
- let clis;
2421
- if (b.clis !== undefined) {
2422
- clis = b.clis;
2423
- }
2424
- else {
2425
- clis = [];
2426
- for (const seat of standing) {
2427
- const key = String(seat.key);
2428
- const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scoped);
2429
- if (admission.ok)
2430
- clis.push(key);
2431
- else
2432
- refused.push({ cliKey: key, reason: admission.reason, source: admission.source });
2441
+ // ASK-C1: admission applies to the DEFAULT roster and to seats NAMED in `clis` alike — the
2442
+ // engine no longer refuses a seat at open (nothing is warmed), so this is the only gate
2443
+ // between a request and the eligible roster the path launches with. The sign-in standing
2444
+ // is part of it for a named seat too: the launch hands the engine the same standing roster,
2445
+ // which benches a signed-out seat (codex on #808 r2, 5) — admitting it here would only move
2446
+ // the refusal to a launch that cannot start. An unknown key is refused by name.
2447
+ const candidates = b.clis !== undefined
2448
+ ? b.clis.map((key) => standing.find((s) => String(s.key) === key) ?? { key })
2449
+ : standing;
2450
+ const clis = [];
2451
+ for (const seat of candidates) {
2452
+ const key = String(seat.key);
2453
+ if (!('auth' in seat)) {
2454
+ refused.push({ cliKey: key, reason: `seat '${key}' is not in the roster`, source: 'scope' });
2455
+ continue;
2433
2456
  }
2457
+ const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scoped, admissionKind);
2458
+ if (admission.ok)
2459
+ clis.push(key);
2460
+ else
2461
+ refused.push({ cliKey: key, reason: admission.reason, source: admission.source });
2434
2462
  }
2435
2463
  if (clis.length === 0) {
2436
- chatScopes.release(chatId, token);
2437
- removeChatScratch(scope.cwd, chatScopes.base);
2464
+ if (chatScopes.release(chatId, token))
2465
+ removeChatScratch(scope.cwd, chatScopes.base);
2438
2466
  return reply.code(409).send({
2439
- error: (scoped
2440
- ? 'no seat in the roster can be held to a scoped chat (an ACP adapter admitted to input ' +
2441
- 'governance, or `os_sandbox = true` on its [cli.acp] record) and take a turn; open the ' +
2442
- 'chat unscoped, sign a seat in, or name seats with `clis`'
2443
- : 'no seat in the roster can take a turn (every seat is signed out); sign a seat in from ' +
2444
- 'the System page, or name seats with `clis`') + ' — see refused',
2467
+ error: (scoped && admissionKind === 'path'
2468
+ ? 'no seat in standing can be held to an ask that reads several repositories (or a project) ' +
2469
+ 'the run cannot bind; scope the ask to one repository, sign a seat in, or open it unscoped'
2470
+ : 'no seat in the roster can take a turn (every seat is signed out or disabled); sign a seat ' +
2471
+ 'in from the System page, or name seats with `clis`') + ' — see refused',
2445
2472
  refused,
2446
2473
  });
2447
2474
  }
2448
2475
  try {
2449
- const seats = await adapter.chatOpen(chatId, clis, engine.cwd, {
2450
- codeGraphDb: engine.codeGraphDb,
2451
- readRoots: engine.readRoots,
2452
- });
2453
- // A REQUESTED seat the engine refused joins the same list, with the engine's reason.
2454
- for (const s of seats) {
2455
- if (!s.ok)
2456
- refused.push({ cliKey: s.cliKey, reason: s.error ?? 'the engine refused the seat', source: 'engine' });
2457
- }
2458
- // F-A45-011 (F-2R2-007 still open): a seat the engine DROPPED — absent from `seats`
2459
- // altogether, neither ok nor refused — used to vanish without a trace (the fresh rig's pi:
2460
- // default chips claude·pi·opencode, 201 `seats:[claude, opencode]`, `refused: []`). It was
2461
- // taken out by the council-bench / dispatch-timeout path, not by admission, so admission's
2462
- // list never named it. Every requested-or-defaulted seat that is not in `seats` is named
2463
- // here with the most specific cause the daemon knows: its own "no credential" report
2464
- // (`auth`), else the dispatch budget (`budget`). (R5b: crew keeps no council bench of its
2465
- // own any more — the engine benches per run and says so in `unitDistributed.degradedReason`.)
2466
- for (const key of clis) {
2467
- if (seats.some((s) => s.cliKey === key) || refused.some((r) => r.cliKey === key))
2468
- continue;
2469
- const st = standingOf(key);
2470
- const authFailure = seatHealth.authFailureFor(key);
2471
- if (authFailure !== null || st?.auth === 'signed_out') {
2472
- refused.push({
2473
- cliKey: key,
2474
- reason: authFailure !== null
2475
- ? `not seated — the seat itself reported no credential (${noCredentialCause(authFailure)}); sign it in from the System page`
2476
- : 'not seated — signed out; sign it in from the System page',
2477
- source: 'auth',
2478
- });
2479
- }
2480
- else {
2481
- refused.push({
2482
- cliKey: key,
2483
- reason: 'not seated — the engine did not warm it within its dispatch budget (the seat timed out or ' +
2484
- 'was dropped at dispatch); check GET /roster and try again, or name seats with `clis`',
2485
- source: 'budget',
2486
- });
2487
- }
2488
- }
2489
- // Nothing warmed (independent review, W1): the engine holds no pool row and has dropped the
2490
- // scope itself (no `chatClosed` will come) — report the PER-SEAT reasons, never an
2491
- // engine-version guess; the root goes, the id is free again.
2492
- if (seats.every((s) => !s.ok)) {
2493
- chatScopes.release(chatId, token);
2494
- removeChatScratch(scope.cwd, chatScopes.base);
2495
- return reply.code(409).send({
2496
- error: `chat ${chatId}: no seat warmed (${seats.length} failed) — see seats`,
2497
- seats,
2476
+ // DES-ASK-TEAM-CHAT-001 §5.1 (ASK-C1): an open resolves the scope and ELIGIBILITY and warms
2477
+ // nothing — the chat is a path its first message launches, and the engine picks the PA
2478
+ // then (`path.started.selection`). Crew owns eligibility; the engine owns the pick.
2479
+ if (b.primary !== undefined && !clis.includes(b.primary)) {
2480
+ if (chatScopes.release(chatId, token))
2481
+ removeChatScratch(scope.cwd, chatScopes.base);
2482
+ return reply.code(400).send({
2483
+ error: `primary names a seat that is not eligible for this chat: ${b.primary}` +
2484
+ ` (eligible: ${clis.join(', ')})`,
2498
2485
  refused,
2499
2486
  });
2500
2487
  }
2501
- // Honest scope (Copilot, #518): a SCOPED chat is a promise — the roots are read-only, the
2502
- // graph is attached, the seats see nothing else — and only an engine that CONFIRMS it
2503
- // recorded the scope (its `chatList` row carries the scope fields) can keep it. An engine
2504
- // predating chat scope (wicked-core#410) dropped `scopeJson` and runs the seats unbounded in
2505
- // the scratch root; an engine that cannot be asked has confirmed nothing. Neither may hold a
2506
- // scoped chat: it is closed again, its root removed, and the caller gets a 501 naming the
2507
- // remedy — never a chat whose statement promises what its seats do not enforce. An UNSCOPED
2508
- // chat (`kind: 'none'`) promises nothing beyond its scratch root and proceeds.
2509
- const applied = await adapter.chatScopeApplied(chatId);
2510
- if (applied !== true && scoped) {
2511
- await adapter.chatClose(chatId).catch(() => undefined);
2512
- // The engine's `chatClosed` for this id is still on its way: park, do not release
2513
- // (Copilot, #518), so a reuse cannot have its root removed by the late event.
2514
- chatScopes.abortToClosing(chatId, token);
2515
- removeChatScratch(scope.cwd, chatScopes.base);
2516
- if (applied === false) {
2517
- // A row WITHOUT the scope fields: the engine predates chat scope (wicked-core#410).
2518
- return reply.code(501).send({
2519
- error: 'the installed wicked-core-ts predates chat scope (wicked-core#410): it cannot ground ' +
2520
- 'a scoped chat or hold its read roots read-only — upgrade the engine, or open the ' +
2521
- 'chat without projectId/repoRefs.',
2522
- seats,
2523
- });
2524
- }
2525
- // NO row although a seat reported warm: nothing is actually held for this chat.
2526
- return reply.code(409).send({
2527
- error: `chat ${chatId}: a seat reported warm but the engine holds no row for the chat — ` +
2528
- 'nothing warmed; open it again (see seats)',
2529
- seats,
2530
- });
2531
- }
2532
- // File the chat into its project WHILE the id is still reserved (Copilot, #518): publishing
2533
- // first would let a concurrent DELETE / engine `chatClosed` land during this await and leave
2534
- // a 201 with a stale scope and a membership attached to a closed chat.
2488
+ const seats = clis.map((cliKey) => ({
2489
+ cliKey,
2490
+ ok: true,
2491
+ }));
2535
2492
  let projectAttachError;
2536
2493
  let attachedMemberId;
2494
+ // Whether THIS open wrote the chat → project index entry (so its cleanup removes only its
2495
+ // own — a newer open of the id may hold one; codex on #808 r4, 2).
2496
+ let indexedHere = false;
2537
2497
  if (b.projectId !== undefined) {
2538
2498
  try {
2539
2499
  const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
2540
2500
  if (created)
2541
2501
  attachedMemberId = member.id;
2542
- if (created) {
2502
+ // The attach awaited: publish the membership only while this open still holds the id —
2503
+ // a cancelled open's filing would overwrite the replacement's (the failed `set` below
2504
+ // detaches what was created).
2505
+ if (created && chatScopes.owns(chatId, token)) {
2543
2506
  projects.index.set(chatId, b.projectId);
2507
+ indexedHere = true;
2544
2508
  projects.bus?.emit(MEMBERSHIP_ATTACHED, { project_id: b.projectId, member: { kind: 'crew.chat', ref: chatId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.chat', chatId, member.attached_at));
2545
2509
  }
2546
2510
  }
@@ -2552,18 +2516,25 @@ runtime = {}) {
2552
2516
  }
2553
2517
  if (!chatScopes.set(chatId, scope, token, refused, engine)) {
2554
2518
  // The reservation was cancelled while the open was in flight — an engine `chatClosed` or a
2555
- // `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (engine
2556
- // session, scratch root, and the filing just made) instead of returning a stale 201.
2557
- await adapter.chatClose(chatId).catch(() => undefined);
2558
- removeChatScratch(scope.cwd, chatScopes.base);
2519
+ // `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (scratch
2520
+ // root and the filing just made) instead of returning a stale 201. No path record exists
2521
+ // yet (it is published below, after the scope is committed), and the scratch root is this
2522
+ // open's only while no newer open holds the id — a replacement opened meanwhile shares the
2523
+ // directory (codex on #808 r2, 3).
2524
+ if (!chatScopes.has(chatId))
2525
+ removeChatScratch(scope.cwd, chatScopes.base);
2559
2526
  if (b.projectId !== undefined && attachedMemberId !== undefined) {
2560
2527
  await adapter.projectMemberDetach(b.projectId, attachedMemberId).catch(() => false);
2561
- projects.index.delete(chatId);
2528
+ if (indexedHere)
2529
+ projects.index.delete(chatId);
2562
2530
  }
2563
2531
  return reply.code(409).send({
2564
2532
  error: `chat ${chatId} was closed while it was being opened; open it again`,
2565
2533
  });
2566
2534
  }
2535
+ // ASK-C1: the path record — published only now, with the scope committed, so a message
2536
+ // cannot launch against a scope that is still being filed (codex on #808 r2, 4).
2537
+ askPaths.open(chatId, clis, b.primary);
2567
2538
  // Register roots for path rewriting (crew#618): absolute host paths in seat replies are
2568
2539
  // rewritten to repo-relative form before being stored in the transcript.
2569
2540
  if (chatTranscripts !== undefined && scope.repos.length > 0) {
@@ -2601,9 +2572,10 @@ runtime = {}) {
2601
2572
  }
2602
2573
  catch (err) {
2603
2574
  // Nothing warmed: the scratch root prepared above must not linger — removed under the SAME
2604
- // base the resolver created it in (Copilot, #518), fail-closed like every removal.
2605
- chatScopes.release(chatId, token);
2606
- removeChatScratch(scope.cwd, chatScopes.base);
2575
+ // base the resolver created it in (Copilot, #518), fail-closed like every removal; only
2576
+ // while the root is still this open's (codex on #808 r3, 3).
2577
+ if (chatScopes.release(chatId, token))
2578
+ removeChatScratch(scope.cwd, chatScopes.base);
2607
2579
  return reply.code(400).send({ error: message(err) });
2608
2580
  }
2609
2581
  }
@@ -2643,73 +2615,170 @@ runtime = {}) {
2643
2615
  if (!parsed.success) {
2644
2616
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
2645
2617
  }
2646
- // DES-L5 §5-c (F-E2E-041): the audience is decided HERE — the named targets, else the engine's
2647
- // warm seats — so the turn can be opened BEFORE the engine call, in the same tick as the
2648
- // predicate below: two racing sends can no longer both pass `inFlight` during the await, and a
2649
- // `chatDelta` that lands before `chatSend` resolves is already stamped with its `turn_id`.
2650
- // crew#641 (review): the ROSTER and the AUDIENCE are different things and only one of them can
2651
- // answer "can this chat disagree with itself". Resolved ONCE here: `audience` is who this turn
2652
- // goes to (the named targets, else everyone warm), `warmRoster` is who is warm at all. A
2653
- // targeted send to one seat of a two-seat chat is not a degraded chat.
2654
- const warmRoster = await adapter.chatSeats(id);
2655
- const audience = parsed.data.targets ?? warmRoster;
2656
- if (audience.length === 0) {
2657
- return reply.code(409).send({ error: `chat '${id}' has no warm seats — open it first` });
2658
- }
2659
- const inFlight = chatTurns.inFlight(id, audience);
2660
- if (inFlight !== null) {
2661
- return reply.code(409).send(turnInFlightBody(id, inFlight, 'Nothing was sent.'));
2618
+ // DES-ASK-TEAM-CHAT-001 §3, §5.1 (ASK-C1): the first message of a chat LAUNCHES one team path
2619
+ // — a user-composed plan of one `understand` step with its gate raised to the turn gate, one
2620
+ // reviewer asked for, the eligible roster as `clis`, the operator's choice as `primary`; every
2621
+ // later message CONTINUES the same path: `propose_plan(answer-N)` then `confirm_gate(Approve)`
2622
+ // at the turn gate the previous answer paused on. Nothing is fanned out: the PA is the one
2623
+ // voice, helpers talk to the PA, the reviewer raises quiet findings — all on the bus.
2624
+ const path = askPaths.get(id);
2625
+ if (path === undefined) {
2626
+ return reply.code(409).send({ error: `chat '${id}' is not open on this daemon — open it first (POST /chats)` });
2627
+ }
2628
+ if (path.eligible.length === 0) {
2629
+ return reply.code(409).send({ error: `chat '${id}' has no eligible seat — sign a seat in and re-seat it (POST /chats/:id/seats)` });
2630
+ }
2631
+ const text = parsed.data.text;
2632
+ const scope = chatScopes.get(id);
2633
+ // A repo-bound path: exactly one repo in scope grounds the run (`repoRef`) and makes it
2634
+ // deliverable; a project or a multi-repo scope binds the project graph only (§5.1 Grounding).
2635
+ const repoRef = scope !== undefined && scope.kind === 'repos' && scope.repos.length === 1 ? scope.repos[0].id : undefined;
2636
+ const projectId = scope?.projectId;
2637
+ const voice = path.pa !== null ? [path.pa] : [];
2638
+ // The turn is reserved for the voice — or, for a random pick (no PA yet), for the whole
2639
+ // eligible roster: the relay narrows it to the PA when `path.started` names one (ASK-C2), so
2640
+ // the PA's frames are stamped with this turn and a second message is refused meanwhile.
2641
+ const audience = voice.length > 0 ? voice : path.eligible;
2642
+ // crew#641/#650 re-stated for a path: ONE eligible seat means no distinct reviewer and no
2643
+ // helper — said on every turn (the engine's `member.joined{seat:null}` says it on the bus).
2644
+ const singleSeat202 = path.eligible.length === 1
2645
+ ? { singleSeat: singleSeatDisclosure(path.eligible[0], chatScopes.refusedOf(id), 'turn') }
2646
+ : {};
2647
+ // F6: a message while the PA is still answering — the previous turn has no reply yet (the
2648
+ // run may already wait at its gate while the relay's reply is a bus poll away; codex on #810
2649
+ // r1, 3) — is refused: nothing is sent, the composer keeps the draft.
2650
+ const pendingTurn = chatTurns.inFlight(id);
2651
+ if (pendingTurn !== null) {
2652
+ return reply.code(409).send(turnInFlightBody(id, pendingTurn, 'Nothing was sent.'));
2653
+ }
2654
+ // One message at a time per chat, on this daemon: the reservation covers the launch, and a
2655
+ // continuation's status read → proposal → approval (two continuations racing would both
2656
+ // read `awaiting_human` and both hold an edit; the second's approval then fails).
2657
+ const lease = askPaths.reserve(id);
2658
+ if (lease === null) {
2659
+ return reply.code(409).send({
2660
+ code: 'turn_in_flight',
2661
+ error: `${path.pa ?? 'the primary agent'} is still answering the previous message in this chat — wait for the reply. Nothing was sent.`,
2662
+ chatId: id,
2663
+ ...(path.runId !== undefined ? { runId: path.runId } : {}),
2664
+ });
2662
2665
  }
2663
- // Sync, same tick as `inFlight`: the window is closed before anything yields.
2664
- const turn = chatTurns.begin(id, audience, parsed.data.text);
2665
- try {
2666
- // DC-S7: a rule remembered since the seats last heard from crew rides this send as a
2667
- // crew-authored preface — disclosed below as a `system` transcript record; the `user` record
2668
- // keeps the operator's own words.
2669
- const preface = await considerations.prefaceForSend(id);
2670
- const seats = await adapter.chatSend(id, preface !== null ? `${preface}\n\n${parsed.data.text}` : parsed.data.text, parsed.data.targets);
2671
- if (turn !== null) {
2672
- // The engine's answer is the truth: the reserved audience is squared with the seats it
2673
- // reached, and the operator's message joins the transcript with exactly those seats.
2674
- chatTurns.reconcile(id, turn.turnId, seats);
2675
- chatTranscripts?.appendUser(id, turn.turnId, parsed.data.text, seats);
2676
- // DC-S4b: who said it, to whom — the recorder matches the seats' quotes against THIS message.
2677
- chatRecorder?.noteSend(id, turn.turnId, actorOf(req), parsed.data.text, seats);
2666
+ const turn = chatTurns.begin(id, audience, text);
2667
+ const turnId = turn?.turnId ?? randomUUID();
2668
+ // What an ACCEPTED message records — the engine has it, so a persistence failure here is
2669
+ // logged, never a retraction of a turn that is already being answered (codex on #808 r2, 9).
2670
+ const persistAccepted = (preface, after) => {
2671
+ try {
2672
+ after();
2678
2673
  if (preface !== null)
2679
- chatTranscripts?.appendSystem(id, turn.turnId, preface);
2674
+ chatTranscripts?.appendSystem(id, turnId, preface);
2675
+ chatTranscripts?.appendUser(id, turnId, text, voice);
2676
+ // The recorder's audience is the turn's AS THE TURN INDEX HOLDS IT NOW: the eligible roster
2677
+ // for a random pick, or the PA the relay narrowed it to during the engine awaits above
2678
+ // (`path.started` / `path.repicked` may land before this record exists — ASK-C2, codex on
2679
+ // #810 r2, 3).
2680
+ const settled = chatTurns.turnsOf(id).find((t) => t.turnId === turnId)?.seats;
2681
+ const pa = askPaths.get(id)?.pa ?? null;
2682
+ chatRecorder?.noteSend(id, turnId, actorOf(req), text,
2683
+ // The turn index's voice; a turn already ended by an early reply names none, so the
2684
+ // path's PA stands in; the reserved audience last.
2685
+ settled !== undefined && settled.length > 0 ? settled : pa !== null ? [pa] : audience);
2680
2686
  }
2681
- // crew#641: re-state single-seat degradation on every turn so it is visible in the transcript.
2682
- // Decided from `warmRoster` — the seats that are WARM — never from `seats`, which is only the
2683
- // seats this turn reached: a targeted send to one seat of a two-seat chat would otherwise
2684
- // announce a degradation that does not exist, and a false "you are degraded" sends an operator
2685
- // after a problem they do not have.
2686
- // crew#650: `refusedOf` is `undefined` for a chat THIS daemon did not open (a restart drops
2687
- // the index) — that is "the refusals are unknown", not "the chat gained a seat". The
2688
- // disclosure is decided by the warm roster alone, and the `undefined` is handed STRAIGHT to
2689
- // the builder (never `?? []`, review of #658): only it may decide what an unknown record is
2690
- // allowed to claim.
2691
- const singleSeat202 = warmRoster.length === 1 ? singleSeatDisclosure(warmRoster[0], chatScopes.refusedOf(id), 'turn') : undefined;
2692
- return reply.code(202).send({
2693
- seats,
2694
- ...(turn !== null ? { turnId: turn.turnId } : {}),
2695
- ...(singleSeat202 !== undefined ? { singleSeat: singleSeat202 } : {}),
2696
- });
2687
+ catch (err) {
2688
+ app.log.warn(`chat ${id}: turn ${turnId} was accepted by the engine but not fully recorded: ${message(err)}`);
2689
+ }
2690
+ };
2691
+ // DC-S7: the rules the chat's seats were told about ride the step's instructions, as the
2692
+ // preface rode a pool send; told only once the send is accepted (`commit`).
2693
+ const withPreface = (preface) => (preface !== null ? `${preface}\n\n${text}` : text);
2694
+ try {
2695
+ if (path.runId === undefined) {
2696
+ const stepId = 'answer-1';
2697
+ const generation = path.generation;
2698
+ const roster = rosterWithStanding().filter((seat) => path.eligible.includes(String(seat.key)));
2699
+ const rules = (await considerations.inForceFor(scope?.projectId ?? null)).rules;
2700
+ const pending = await considerations.pendingPreface(id, { inForce: rules });
2701
+ const instructions = withPreface(pending.preface);
2702
+ const input = {
2703
+ problem: scope !== undefined ? chatScopeStatement(id, scope, rules) : text,
2704
+ sessionId: randomUUID(),
2705
+ clisJson: JSON.stringify(roster),
2706
+ plan: { steps: [answerStep(stepId, instructions)], monitors: { asked: 1 } },
2707
+ ...(path.primary !== undefined ? { primary: path.primary } : {}),
2708
+ ...(repoRef !== undefined ? { repoRef, deliver: 'pr' } : {}),
2709
+ ...(projectId !== undefined ? { projectId } : {}),
2710
+ };
2711
+ const runId = await adapter.launchRun(input);
2712
+ if (!askPaths.started(id, generation, runId, stepId)) {
2713
+ // The chat was closed (End) while the launch was in flight: the run must not outlive it.
2714
+ await adapter.cancelRun(runId).catch(() => undefined);
2715
+ if (turn !== null)
2716
+ chatTurns.abort(id, turn.turnId);
2717
+ return reply.code(409).send({ error: `chat '${id}' was closed while its path was launching; the run was cancelled` });
2718
+ }
2719
+ pending.commit();
2720
+ persistAccepted(pending.preface, () => {
2721
+ runtime.linkChatRun?.(id, runId);
2722
+ recordRunLaunched(audit, runTimingIndex, actorOf(req), runId, {
2723
+ chatId: id,
2724
+ askPath: true,
2725
+ ...(repoRef !== undefined ? { repoRef } : {}),
2726
+ ...(projectId !== undefined ? { projectId } : {}),
2727
+ deliver: repoRef !== undefined ? 'pr' : 'none',
2728
+ });
2729
+ });
2730
+ return reply.code(202).send({ seats: voice, turnId, runId, stepId, ...singleSeat202 });
2731
+ }
2732
+ const view = (await adapter.sessionsDetail()).find((v) => v.session.id === path.runId);
2733
+ if (view === undefined) {
2734
+ askPaths.release(id, lease);
2735
+ if (turn !== null)
2736
+ chatTurns.abort(id, turn.turnId);
2737
+ return reply.code(409).send({ error: `chat '${id}': its path ${path.runId} is gone — open a new chat` });
2738
+ }
2739
+ const status = String(view.session.status);
2740
+ if (status !== 'awaiting_human') {
2741
+ askPaths.release(id, lease);
2742
+ if (turn !== null)
2743
+ chatTurns.abort(id, turn.turnId);
2744
+ const who = path.pa ?? 'the primary agent';
2745
+ return reply.code(409).send({
2746
+ code: 'turn_in_flight',
2747
+ error: status === 'executing' || status === 'running'
2748
+ ? `${who} is still answering the previous message in this chat — wait for the reply. Nothing was sent.`
2749
+ : `chat '${id}': its path ${path.runId} is ${status}, not waiting for your turn — nothing was sent`,
2750
+ chatId: id,
2751
+ runId: path.runId,
2752
+ status,
2753
+ });
2754
+ }
2755
+ // The status read awaited: the chat may have been closed (and its id reopened) meanwhile —
2756
+ // nothing of this message may land on the newcomer (codex on #808 r3, 2).
2757
+ if (askPaths.get(id)?.generation !== lease) {
2758
+ if (turn !== null)
2759
+ chatTurns.abort(id, turn.turnId);
2760
+ return reply.code(409).send({ error: `chat '${id}' was closed while the message was in flight; nothing was sent` });
2761
+ }
2762
+ const stepId = askPaths.nextStep(id);
2763
+ const pending = await considerations.pendingPreface(id);
2764
+ await adapter.proposePlan(path.runId, { steps: [answerStep(stepId, withPreface(pending.preface))] }, turnId);
2765
+ await adapter.confirmGate(path.runId, true);
2766
+ askPaths.release(id, lease);
2767
+ if (!askPaths.continued(id, lease, stepId)) {
2768
+ // Closed between the proposal and here: the DELETE cancelled the run this was accepted on.
2769
+ return reply.code(409).send({ error: `chat '${id}' was closed while the message was in flight; its path was cancelled` });
2770
+ }
2771
+ pending.commit();
2772
+ persistAccepted(pending.preface, () => undefined);
2773
+ return reply.code(202).send({ seats: voice, turnId, runId: path.runId, stepId, ...singleSeat202 });
2697
2774
  }
2698
2775
  catch (err) {
2699
- // Nothing went out: the reservation is retracted so the next send is not refused for it.
2776
+ askPaths.release(id, lease);
2700
2777
  if (turn !== null)
2701
2778
  chatTurns.abort(id, turn.turnId);
2702
- const msg = message(err);
2703
- return reply.code(/no warm seats/.test(msg) ? 409 : 400).send({ error: msg });
2779
+ return reply.code(400).send({ error: message(err) });
2704
2780
  }
2705
2781
  });
2706
- // F-W1-005 (wave-1 P6): re-seat NAMED seats on a LIVE chat — the retry lever for a seat the open
2707
- // refused (or whose session died). Re-runs the engine's per-seat `chat_ensure` through `chatOpen`
2708
- // with the IDENTICAL scope recorded at open (a different scope would evict every warm seat —
2709
- // Copilot, #426; the index retains what the engine was handed). Explicit seats bypass the daemon's
2710
- // default pre-filter exactly as `clis` on `POST /chats` does: the engine refuses per seat and its
2711
- // reason rides the outcome, then `refused[]` and a `chatSeatRefused` frame — the same shapes the
2712
- // 201 carries, so the thread and the picker read one story. Never a new chat, never a new pool key.
2713
2782
  const ChatSeatsSchema = z.object({
2714
2783
  clis: z.array(z.string().min(1)).min(1).max(8),
2715
2784
  }).strict();
@@ -2732,7 +2801,10 @@ runtime = {}) {
2732
2801
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
2733
2802
  }
2734
2803
  const engine = chatScopes.engineOf(id);
2735
- if (engine === undefined) {
2804
+ // ASK-C1: a chat is open here when it has a path record (every open leaves one, scoped or
2805
+ // not) — the engine scope alone is the pool-era test.
2806
+ const launched = askPaths.get(id);
2807
+ if (engine === undefined && launched === undefined) {
2736
2808
  return reply.code(404).send({
2737
2809
  error: `chat ${id} is not open on this daemon — open a chat first, then re-seat into it`,
2738
2810
  });
@@ -2743,25 +2815,40 @@ runtime = {}) {
2743
2815
  if (inFlight !== null) {
2744
2816
  return reply.code(409).send(turnInFlightBody(id, inFlight, 'No seat was re-warmed.'));
2745
2817
  }
2818
+ // ASK-C1: the path's roster is handed to the engine at launch and never updated after —
2819
+ // a seat admitted later would be reported eligible and never reach the run (codex on #808
2820
+ // r2, 6). Re-seat before the first message; after it, a new chat.
2821
+ if (launched?.runId !== undefined) {
2822
+ return reply.code(409).send({
2823
+ error: `chat '${id}' has launched its path (${launched.runId}); its seats are fixed for this ask — open a new chat to seat ${clis.join(', ')}`,
2824
+ chatId: id,
2825
+ runId: launched.runId,
2826
+ });
2827
+ }
2828
+ if (launched?.busy === true) {
2829
+ // The first message is launching with the roster as it stands (codex on #808 r3, 4).
2830
+ return reply.code(409).send({
2831
+ code: 'turn_in_flight',
2832
+ error: `chat '${id}' is launching its path with the seats as they stand — a seat added now would never reach the run. Nothing was re-seated.`,
2833
+ chatId: id,
2834
+ });
2835
+ }
2746
2836
  try {
2747
- const seats = await adapter.chatOpen(id, clis, engine.cwd, {
2748
- codeGraphDb: engine.codeGraphDb,
2749
- readRoots: engine.readRoots,
2837
+ // ASK-C1: a re-seat re-admits the named seats that pass admission NOW (a sign-in fixed)
2838
+ // into the path's eligible roster; nothing is warmed and the engine is not called.
2839
+ const scopeNow = chatScopes.get(id);
2840
+ const scopedChat = (scopeNow?.kind ?? 'none') !== 'none' && scopeNow?.kind !== 'system';
2841
+ const reseatKind = scopeNow?.kind === 'repos' && scopeNow.repos.length === 1 ? 'path-bound' : 'path';
2842
+ const standingNow = rosterWithStanding();
2843
+ const seats = clis.map((cliKey) => {
2844
+ const seat = standingNow.find((s) => String(s.key) === cliKey);
2845
+ if (seat === undefined)
2846
+ return { cliKey, ok: false, error: `seat '${cliKey}' is not in the roster` };
2847
+ const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scopedChat, reseatKind);
2848
+ return admission.ok ? { cliKey, ok: true } : { cliKey, ok: false, error: admission.reason };
2750
2849
  });
2850
+ askPaths.admit(id, seats.filter((s) => s.ok).map((s) => s.cliKey));
2751
2851
  const refused = chatScopes.foldSeats(id, seats);
2752
- const projectId = projects.index.projectOf(id) ?? undefined;
2753
- for (const s of seats) {
2754
- if (s.ok)
2755
- continue;
2756
- runtime.broadcast?.({
2757
- type: 'chatSeatRefused',
2758
- chat: id,
2759
- cliKey: s.cliKey,
2760
- reason: s.error ?? 'the engine refused the seat',
2761
- source: 'engine',
2762
- ...(projectId !== undefined ? { project_id: projectId } : {}),
2763
- });
2764
- }
2765
2852
  return { chatId: id, seats, refused };
2766
2853
  }
2767
2854
  catch (err) {
@@ -2775,7 +2862,24 @@ runtime = {}) {
2775
2862
  // segment first), but the order reads the way the routes nest.
2776
2863
  app.get(`${V}/chats`, { config: { manifest: { responseType: 'ChatListResponse', statusCodes: [200, 400, 501] } } }, async (_req, reply) => {
2777
2864
  try {
2778
- return { chats: await adapter.chatList() };
2865
+ const pool = await adapter.chatList().catch((err) => {
2866
+ if (err instanceof ChatUnsupportedError)
2867
+ return [];
2868
+ throw err;
2869
+ });
2870
+ // ASK-C1: an ask path is a chat of this daemon with no engine pool row — listed from the
2871
+ // ask-path index (the pool entries stay until ASK-K4 deletes the pool).
2872
+ const paths = askPaths.list().map((p) => {
2873
+ const engine = chatScopes.engineOf(p.chatId);
2874
+ return {
2875
+ chatId: p.chatId,
2876
+ seats: [...p.eligible],
2877
+ idleSecs: null,
2878
+ ...(engine !== undefined ? { cwd: engine.cwd, codeGraphDb: engine.codeGraphDb ?? null, readRoots: [...engine.readRoots] } : {}),
2879
+ };
2880
+ });
2881
+ const seen = new Set(paths.map((p) => p.chatId));
2882
+ return { chats: [...paths, ...pool.filter((c) => !seen.has(c.chatId))] };
2779
2883
  }
2780
2884
  catch (err) {
2781
2885
  // A build that cannot do chat is a capability gap, not a bad request: 501 tells an operator to
@@ -2811,9 +2915,12 @@ runtime = {}) {
2811
2915
  // the seats refused at open (F-A45-011), so the studio's admission copy survives a reload.
2812
2916
  return {
2813
2917
  chatId: id,
2814
- seats: await adapter.chatSeats(id),
2918
+ // ASK-C1: the eligible roster is the chat's seats (no warm pool); a chat this daemon
2919
+ // did not open answers `[]`.
2920
+ seats: askPaths.get(id)?.eligible ?? [],
2815
2921
  scope: chatScopes.get(id) ?? null,
2816
2922
  refused: chatScopes.refusedOf(id) ?? null,
2923
+ ...(askPaths.view(id) !== undefined ? { path: askPaths.view(id) } : {}),
2817
2924
  // DES-L5 (D-13): the transcript so far, append order — `[]` before the first persisted
2818
2925
  // turn; the file goes with the chat on `chatClosed`, so a reclaimed id answers `[]` too.
2819
2926
  ...(chatTranscripts !== undefined ? { messages: chatTranscripts.read(id) } : {}),
@@ -2826,17 +2933,49 @@ runtime = {}) {
2826
2933
  app.delete(`${V}/chats/:id`, async (req, reply) => {
2827
2934
  const { id } = req.params;
2828
2935
  // The scratch root goes with the chat at once, whether or not the engine still knew it
2829
- // (crew#502); the id stays parked until the engine's `chatClosed` is observed, so a delayed
2830
- // close can never land on a chat that reused it (Copilot, #518).
2831
- chatScopes.beginClose(id);
2936
+ // (crew#502). ASK-C1: the daemon owns the close, so the id is HELD — no grace timer — until
2937
+ // this close settles below; a reopen meanwhile is refused and a second DELETE joins this one
2938
+ // (nothing of the closed chat can land on a reuse — codex on #808 r5).
2939
+ const ticket = chatScopes.holdClose(id);
2940
+ if (ticket === null)
2941
+ return { ok: true };
2832
2942
  chatTurns.closed(id);
2943
+ // ASK-C1: End cancels the path (`path.ended{status:"cancelled"}`); the engine holds no chat
2944
+ // row for an ask, so the daemon closes the id itself and the transcript follows the chat
2945
+ // through the same `chatClosed` fold a pool chat used (dropped unless a promoted run retains it).
2946
+ const path = askPaths.close(id);
2833
2947
  try {
2834
- await adapter.chatClose(id);
2948
+ const projectId = projects.index.projectOf(id) ?? undefined;
2949
+ const frame = {
2950
+ type: 'chatClosed',
2951
+ chat: id,
2952
+ reason: 'closed',
2953
+ ...(projectId !== undefined ? { project_id: projectId } : {}),
2954
+ };
2955
+ // The server's fold (scope slot, decisions, considerations, retention-aware transcript
2956
+ // drop) and the /ws fan-out — the same path an engine `chatClosed` took. Folded BEFORE the
2957
+ // awaited cancel: a close that lands after an await can land on a chat that reused the id
2958
+ // meanwhile (codex on #808 r2, 2). AWAITED: the decision recorder settles the chat's open
2959
+ // turns under the chat's own project before the id is free (the slot is parked `closing`
2960
+ // meanwhile, so a reopen waits), and only then is nothing of the closed chat pending
2961
+ // (codex on #808 r4, 1).
2962
+ if (runtime.closeChat !== undefined)
2963
+ await runtime.closeChat(frame, path?.runId, ticket);
2964
+ else {
2965
+ chatScopes.closeHeld(id, ticket);
2966
+ runtime.broadcast?.(frame);
2967
+ }
2968
+ if (path?.runId !== undefined)
2969
+ await adapter.cancelRun(path.runId).catch(() => undefined);
2835
2970
  return { ok: true };
2836
2971
  }
2837
2972
  catch (err) {
2838
2973
  return reply.code(400).send({ error: message(err) });
2839
2974
  }
2975
+ finally {
2976
+ // Never left held: a fold that threw frees the id here (its own close settles it otherwise).
2977
+ chatScopes.closeHeld(id, ticket);
2978
+ }
2840
2979
  });
2841
2980
  // A unit's captured transcript. `unitKey` accepts the fully-qualified id (`run-1:survey`),
2842
2981
  // the id suffix (`survey`, `u3`), or the ordinal (`u3`, `3`) — see `resolveUnit`.
@@ -3689,7 +3828,8 @@ runtime = {}) {
3689
3828
  const dryRun = parsed.data.dryRun === true;
3690
3829
  const store = adapter.governanceStore ?? null;
3691
3830
  if (store === null) {
3692
- return reply.code(409).send({ error: 'this daemon resolved no governance store — there is nothing to replay into' });
3831
+ // crew#829: the refusal names the one-line fix; a replay is offered once the store exists.
3832
+ return reply.code(409).send({ error: `this daemon resolved no governance store — there is nothing to replay into; ${governanceStoreFix()}` });
3693
3833
  }
3694
3834
  const blocker = store.dbPath === ':memory:'
3695
3835
  ? 'the governance store is in-memory — name a durable store before replaying'
@@ -5239,6 +5379,8 @@ runtime = {}) {
5239
5379
  registerInteractiveDocList(app, adapter, {
5240
5380
  settings: projectSettings,
5241
5381
  pool: interactiveBridges,
5382
+ // crew#512: the rows carry the grounding the draft seam recorded beside each doc.
5383
+ ...(runtime.docGrounding !== undefined ? { groundingStore: runtime.docGrounding } : {}),
5242
5384
  log: (m) => app.log.warn(m),
5243
5385
  });
5244
5386
  // EP-C2: the ONE checks read of a document — the reviewers' recorded verdicts, read-only.