wicked-crew 0.7.38 → 0.7.40

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 (82) hide show
  1. package/dist/api/chat-scope.d.ts +37 -1
  2. package/dist/api/chat-scope.d.ts.map +1 -1
  3. package/dist/api/chat-scope.js +133 -9
  4. package/dist/api/chat-scope.js.map +1 -1
  5. package/dist/api/chat-transcripts.d.ts +45 -2
  6. package/dist/api/chat-transcripts.d.ts.map +1 -1
  7. package/dist/api/chat-transcripts.js +89 -5
  8. package/dist/api/chat-transcripts.js.map +1 -1
  9. package/dist/api/routes.d.ts +70 -10
  10. package/dist/api/routes.d.ts.map +1 -1
  11. package/dist/api/routes.js +206 -20
  12. package/dist/api/routes.js.map +1 -1
  13. package/dist/api/run-timing-index.d.ts +22 -0
  14. package/dist/api/run-timing-index.d.ts.map +1 -1
  15. package/dist/api/run-timing-index.js +63 -0
  16. package/dist/api/run-timing-index.js.map +1 -1
  17. package/dist/api/server.d.ts.map +1 -1
  18. package/dist/api/server.js +120 -17
  19. package/dist/api/server.js.map +1 -1
  20. package/dist/api/testing.d.ts +26 -0
  21. package/dist/api/testing.d.ts.map +1 -1
  22. package/dist/api/testing.js +48 -2
  23. package/dist/api/testing.js.map +1 -1
  24. package/dist/api/worktree-sweep.d.ts +12 -0
  25. package/dist/api/worktree-sweep.d.ts.map +1 -0
  26. package/dist/api/worktree-sweep.js +41 -0
  27. package/dist/api/worktree-sweep.js.map +1 -0
  28. package/dist/cli/bearer.d.ts +8 -0
  29. package/dist/cli/bearer.d.ts.map +1 -0
  30. package/dist/cli/bearer.js +16 -0
  31. package/dist/cli/bearer.js.map +1 -0
  32. package/dist/cli/index.d.ts +1 -1
  33. package/dist/cli/index.d.ts.map +1 -1
  34. package/dist/cli/index.js +21 -7
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/core/adapter.d.ts +11 -2
  37. package/dist/core/adapter.d.ts.map +1 -1
  38. package/dist/core/adapter.js +23 -4
  39. package/dist/core/adapter.js.map +1 -1
  40. package/dist/interactive/demo-events.d.ts +3 -0
  41. package/dist/interactive/demo-events.d.ts.map +1 -1
  42. package/dist/interactive/demo-events.js +14 -1
  43. package/dist/interactive/demo-events.js.map +1 -1
  44. package/dist/interactive/doc-grounding.d.ts +6 -0
  45. package/dist/interactive/doc-grounding.d.ts.map +1 -1
  46. package/dist/interactive/doc-grounding.js +4 -0
  47. package/dist/interactive/doc-grounding.js.map +1 -1
  48. package/dist/interactive/draft-events.d.ts +4 -0
  49. package/dist/interactive/draft-events.d.ts.map +1 -1
  50. package/dist/interactive/draft-events.js +12 -2
  51. package/dist/interactive/draft-events.js.map +1 -1
  52. package/dist/interactive/proxy-routes.d.ts +14 -0
  53. package/dist/interactive/proxy-routes.d.ts.map +1 -1
  54. package/dist/interactive/proxy-routes.js +98 -3
  55. package/dist/interactive/proxy-routes.js.map +1 -1
  56. package/dist/interactive/repo-snapshot.d.ts.map +1 -1
  57. package/dist/interactive/repo-snapshot.js +3 -2
  58. package/dist/interactive/repo-snapshot.js.map +1 -1
  59. package/dist/projects/graph.d.ts +11 -0
  60. package/dist/projects/graph.d.ts.map +1 -1
  61. package/dist/projects/graph.js +12 -0
  62. package/dist/projects/graph.js.map +1 -1
  63. package/dist/qe/author-workflow.d.ts +2 -0
  64. package/dist/qe/author-workflow.d.ts.map +1 -1
  65. package/dist/qe/author-workflow.js +55 -12
  66. package/dist/qe/author-workflow.js.map +1 -1
  67. package/dist/skills/phase-skill-gaps.d.ts +108 -0
  68. package/dist/skills/phase-skill-gaps.d.ts.map +1 -0
  69. package/dist/skills/phase-skill-gaps.js +199 -0
  70. package/dist/skills/phase-skill-gaps.js.map +1 -0
  71. package/dist/skills/runtime.d.ts +1 -1
  72. package/dist/skills/runtime.d.ts.map +1 -1
  73. package/dist/skills/runtime.js +2 -1
  74. package/dist/skills/runtime.js.map +1 -1
  75. package/dist/studio/assets/index-Bg7Z3BR-.css +32 -0
  76. package/dist/studio/assets/index-BvOFMjf9.js +554 -0
  77. package/dist/studio/index.html +2 -2
  78. package/dist/studio/testid-inventory.json +100 -10
  79. package/endpoint-manifest.json +1 -1
  80. package/package.json +3 -3
  81. package/dist/studio/assets/index-C12tVMLX.css +0 -32
  82. package/dist/studio/assets/index-DFkL6Rzf.js +0 -546
@@ -20,13 +20,13 @@ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
20
20
  import { chatSeatAdmission } from './seat-standing.js';
21
21
  import { rosterWithStandingFactory } from './roster-standing.js';
22
22
  import { ChatTurnIndex } from './chat-turns.js';
23
- import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
23
+ import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
24
24
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
25
25
  import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
26
26
  import { DocRunIndex } from '../interactive/doc-run-index.js';
27
27
  import { listInteractiveDocs } from '../interactive/docs-index.js';
28
28
  import { TestSetIndex } from '../qe/test-sets.js';
29
- import { estateExe, resolveProjectGraphBinding } from '../projects/graph.js';
29
+ import { estateExe, parseEstateTotals, resolveProjectGraphBinding } from '../projects/graph.js';
30
30
  import { registerProjectRoutes } from '../projects/routes.js';
31
31
  import { registerCampaignRoutes } from '../campaigns/routes.js';
32
32
  import { registerGovernanceWikiRoutes } from './governance-wiki.js';
@@ -35,6 +35,7 @@ import { isSteeringAuthorRun, landSteeringProposal } from './steering-landing.js
35
35
  import { registerTestingRoutes } from './testing.js';
36
36
  import { registerSkillsRoutes } from './skills.js';
37
37
  import { disabledSkillsHealth } from '../skills/runtime.js';
38
+ import { phaseSkillFindings, withPhaseSkillGaps } from '../skills/phase-skill-gaps.js';
38
39
  import { STATE_HOME_BLOCKER_CODE, STATE_HOME_REMEDY, isEngineStateHomeRefusal, stateHomeBlockerBody, } from '../projects/state-home-preflight.js';
39
40
  import { BASE_SKILL_POLICIES, BASE_SKILL_REF_SHAPE, baseSkillRemedy } from '../skills/base-skill.js';
40
41
  import { noEligibleSeatBody, parseNoEligibleSeat } from '../core/engine-roster.js';
@@ -83,17 +84,10 @@ const STATUS_ORDER = {
83
84
  function sortActionableFirst(views) {
84
85
  return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
85
86
  }
86
- /**
87
- * The whole-graph counts from `wicked-estate stats --db` stdout (`nodes=N edges=M files=F …`, the
88
- * first line estate prints — `main.rs`'s `stats` summary). `undefined` when the line does not parse
89
- * (an older/newer estate, an error message): the caller leaves `CodeGraphData.totals` ABSENT.
90
- */
91
- export function parseEstateTotals(stdout) {
92
- const m = /^nodes=(\d+) edges=(\d+) files=(\d+)/m.exec(stdout);
93
- if (m === null)
94
- return undefined;
95
- return { nodes: Number(m[1]), edges: Number(m[2]), files: Number(m[3]) };
96
- }
87
+ // parseEstateTotals lives in projects/graph.ts — shared with chat-scope.ts (crew#642) to avoid
88
+ // a circular import (routes.ts → chat-scope.ts). Re-exported here for backward compat
89
+ // (tests import it from routes.js; see top-of-file import for local use).
90
+ export { parseEstateTotals };
97
91
  function codeGraphDbOr503(repo, reply) {
98
92
  try {
99
93
  return codeGraphDb(repo);
@@ -383,6 +377,18 @@ export const LaunchSchema = z.object({
383
377
  * Resolved via `gh pr view` at launch — not OPEN / a fork / gh failure ⇒ 409, nothing launched.
384
378
  * Send it only when `GET /health.capabilities.revisesPr === true` (engine ≥ 0.7.27). */
385
379
  revisesPr: z.number().int().positive().optional(),
380
+ /** crew#619 — the chat this run was promoted from; the daemon retains that chat's transcript
381
+ * on disk until this run reaches a terminal state so the Continue-in-Build prefill is
382
+ * always reproducible. Optional; omit when the launch is not promoted from a chat. */
383
+ chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
384
+ /** crew#632 — the surface that triggered this launch: `studio` (the web UI), `cli` (the
385
+ * wicked-crew CLI), or `api` (a programmatic caller). Persisted on the `run.launched` audit
386
+ * entry (`detail.channel`) and served on the run DTO; absent = origin unknown. */
387
+ channel: z.enum(['studio', 'cli', 'api']).optional(),
388
+ /** crew#632 — an opaque caller-supplied identifier for the user or system that triggered the
389
+ * launch (e.g. a CI job name or a studio tab id). Persisted on the `run.launched` audit entry
390
+ * (`detail.actor`) and served on the run DTO; absent = caller omitted it. */
391
+ actor: z.string().min(1).max(256).optional(),
386
392
  }).strict().refine((b) => b.deliver !== 'pr' || b.workflow !== undefined, {
387
393
  message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
388
394
  path: ['deliver'],
@@ -514,7 +520,82 @@ export const ChatOpenSchema = z.object({
514
520
  /** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
515
521
  * every registered `crew.repo` member of that project (crew#502). */
516
522
  projectId: z.string().min(1).optional(),
523
+ /** The scope the caller NAMES (studio#323 R4) — omitted ⇒ the legacy inference. Shape rules
524
+ * (which kinds need / refuse `projectId` / `repoRefs`) are the resolver's (`chat-scope.ts`). */
525
+ scopeKind: z.enum(['system', 'everything', 'project', 'repo', 'repos', 'none']).optional(),
517
526
  }).strict();
527
+ /**
528
+ * The `singleSeat` disclosure (crew#641, hole closed in crew#650) — built in ONE place so the 201
529
+ * and the 202 cannot drift.
530
+ *
531
+ * The property this field states is **"this chat has one seat; it cannot disagree with itself"**,
532
+ * and that is true of every one-warm-seat chat. The first cut also required `refused.length > 0`,
533
+ * so the case an operator is most likely to create — a chat deliberately opened with a single seat
534
+ * — returned `ok: true` with no signal at all, which is the exact failure mode #641 was filed
535
+ * about. It also made the 202 go quiet after a restart, when `refusedOf` no longer knows the
536
+ * refusals, even though the chat is just as single-seated as it was a minute earlier.
537
+ *
538
+ * `refused` therefore rides along as EVIDENCE when there is any — it never decides whether the
539
+ * caller is told.
540
+ *
541
+ * THREE states, not two (review of #658). The refusal list has a third possibility that `[]` cannot
542
+ * express: this daemon does not KNOW. `chatScopes.refusedOf` answers `undefined` for a chat this
543
+ * daemon did not open — the engine keeps the warm session across a restart, the in-memory index does
544
+ * not — and the first cut collapsed that to `[]` with `?? []`, which made the 202 tell an operator
545
+ * *"It was opened with a single seat — no other seat was refused"* about a chat that was opened with
546
+ * two seats, one of which was refused. Both halves false, stated confidently. It is the same
547
+ * distinction #651 got right for chat-promotion provenance (`.catch(() => null)`, never `[]`):
548
+ * unreadable and empty are different answers, and only one of them may be asserted.
549
+ *
550
+ * TWO MOMENTS, and only one route witnessed each (third review of #658). `refused` describes the
551
+ * daemon's record — what the OPEN (or the last re-seat) observed; `warmRoster` describes NOW. Any
552
+ * sentence built from both fabricates the instant they diverge, and they diverge routinely: a seat
553
+ * that blows its turn budget is RELEASED by the engine (`chat-turns.ts`), a re-seat clears an entry
554
+ * from the record, a restart drops the record entirely. A chat opened with `['claude','opencode']`
555
+ * — both warm, nothing refused, nothing unknown — whose `opencode` is later released would have been
556
+ * told *"It was opened with a single seat — no other seat was refused"*: false in both halves, with
557
+ * no `undefined` anywhere near it. So `at` names the moment the caller is asking about:
558
+ *
559
+ * • `'open'` — the 201. This route just performed the open, so it is the ONLY one with standing to
560
+ * describe it: refusals known and empty ⇒ it may say the chat was opened with a single seat.
561
+ * • `'turn'` — the 202. It knows the roster NOW and the refusal record; it knows nothing about how
562
+ * the chat came to look like this. Refusals known and empty ⇒ it says NOTHING about opening.
563
+ * It still names refusals that are on record, and still says so when the record is unavailable —
564
+ * both are statements about the record it holds, not about a moment it did not see.
565
+ *
566
+ * `refusalsKnown` carries the known/unknown distinction for a machine reader, because `refused: []`
567
+ * is exactly as ambiguous on the wire as the sentence was in prose.
568
+ */
569
+ function singleSeatDisclosure(warmed,
570
+ /** `undefined` = this daemon has no refusal record for the chat (it did not open it). */
571
+ refused,
572
+ /** Which moment the caller is asking about — see the note above; only `'open'` may describe one. */
573
+ at) {
574
+ const known = refused !== undefined;
575
+ const list = refused ?? [];
576
+ const refusalClause = list.length > 0
577
+ ? `Refused: ${list.map((r) => `${r.cliKey} (${r.reason})`).join('; ')}. `
578
+ : known
579
+ ? at === 'open'
580
+ ? 'It was opened with a single seat — no other seat was refused. '
581
+ : // A turn has no standing to say how the chat was opened: this seat may be the survivor of
582
+ // a two-seat chat whose other seat was released mid-session. Say nothing.
583
+ ''
584
+ : 'This daemon holds no refusal record for this chat — it was opened before a restart — so ' +
585
+ 'whether another seat was refused is UNKNOWN: the empty refused[] means the record is gone, ' +
586
+ 'not that nothing was refused. ';
587
+ return {
588
+ degraded: true,
589
+ warmed,
590
+ refused: list,
591
+ refusalsKnown: known,
592
+ message:
593
+ // "warm" on a turn: the claim is about the roster as it stands, not about how it got there.
594
+ `This chat has one ${at === 'open' ? 'seat' : 'warm seat'} (${warmed}); it cannot disagree with itself. ` +
595
+ refusalClause +
596
+ 'The single-seat root cause is tracked as wicked-core#563; this chat makes it visible.',
597
+ };
598
+ }
518
599
  export function registerRoutes(app, adapter, gateCache, elicitationCache,
519
600
  // Defaulted so a caller that never arms the bus seam (tests drive this
520
601
  // function directly) gets the same behavior as an unarmed daemon: an empty
@@ -661,6 +742,10 @@ runtime = {}) {
661
742
  const guidance = guidanceIndex.guidanceFor(view.session.id);
662
743
  if (guidance !== undefined)
663
744
  view.session.guidance = guidance;
745
+ // crew#661: the declared skills this run's phases ran WITHOUT (degrade-and-disclose) — ABSENT when none.
746
+ const skillGaps = runtime.runSkillGaps?.gapsFor(view.session.id);
747
+ if (skillGaps !== undefined)
748
+ view.session.skill_gaps = skillGaps;
664
749
  // Run launch time (home command-center run metrics): unix SECONDS from the `run.launched`
665
750
  // audit entry, ABSENT when the daemon has no launch record for this run (onboarding/campaign
666
751
  // runs launched off POST /runs, pre-field runs) — never fabricated, so a bucketed KPI can
@@ -673,10 +758,24 @@ runtime = {}) {
673
758
  const endedAt = runTimingIndex.endedAtFor(view.session.id);
674
759
  if (endedAt !== undefined)
675
760
  view.session.ended_at = endedAt;
761
+ // crew#632: launch surface and caller identity — served ABSENT when absent, never null.
762
+ const channel = runTimingIndex.channelFor(view.session.id);
763
+ if (channel !== undefined)
764
+ view.session.channel = channel;
765
+ const launchActor = runTimingIndex.launchActorFor(view.session.id);
766
+ if (launchActor !== undefined)
767
+ view.session.launch_actor = launchActor;
676
768
  const state = resolveDelivery(view, conflictStrand);
677
769
  view.session.delivery = state.delivery;
678
770
  if (state.deliverUrl !== undefined)
679
771
  view.session.deliverUrl = state.deliverUrl;
772
+ // crew#641/#642 (item 5): chat promotion provenance — ABSENT when not a chat-promoted run.
773
+ const chatSeatCount = runTimingIndex.chatSeatCountFor(view.session.id);
774
+ if (chatSeatCount !== undefined)
775
+ view.session.chat_seat_count = chatSeatCount;
776
+ const chatGrounded = runTimingIndex.chatGroundedFor(view.session.id);
777
+ if (chatGrounded !== undefined)
778
+ view.session.chat_grounded = chatGrounded;
680
779
  return view;
681
780
  };
682
781
  // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
@@ -700,7 +799,7 @@ runtime = {}) {
700
799
  // probe honestly reports no gate.
701
800
  const capabilities = typeof adapter.engineCapabilities === 'function'
702
801
  ? adapter.engineCapabilities()
703
- : { deliverGate: false, revisesPr: false };
802
+ : { deliverGate: false, revisesPr: false, chatIdOnLaunch: false, seatChipOnCreate: false };
704
803
  // wicked-core#411 / crew#497: the state-home blocker rides the health probe as a WARNING. The
705
804
  // daemon still SERVES (status stays ok — studio must load and show the blocker) but refuses to
706
805
  // launch while the state home holds an entry the worker Read fence cannot classify. Re-surveyed
@@ -716,9 +815,12 @@ runtime = {}) {
716
815
  // `baseSkill.finding` and `/diagnostics.skills.findings[]` (one surface, no new field;
717
816
  // `HealthWarning.kind` is open); status stays ok because the daemon serves and studio must load
718
817
  // and show it — exactly the state-home blocker's precedent above.
818
+ // crew#661: a drafting seam that armed WITHOUT its declared skill rides `warnings` as well — its
819
+ // runs proceed degraded, so "status ok, no warnings" would be the silent answer the issue names.
719
820
  const warnings = [
720
821
  ...(stateHome === null ? [] : stateHome.findings.map((f) => ({ kind: f.kind, severity: f.severity, message: f.message }))),
721
822
  ...(baseSkill?.finding ? [{ kind: baseSkill.finding.kind, severity: baseSkill.finding.severity, message: baseSkill.finding.message }] : []),
823
+ ...phaseSkillFindings(runtime.phaseSkills?.gaps() ?? []),
722
824
  ];
723
825
  return {
724
826
  status: 'ok',
@@ -771,7 +873,9 @@ runtime = {}) {
771
873
  // The skills seam's last outcome (skills keystone): published / fallback / blocked /
772
874
  // config-error, with the `skills.*` findings the ladder produced — the operator's one
773
875
  // read-only answer to "why do launches refuse the snapshot".
774
- skills: runtime.skills?.health() ?? disabledSkillsHealth(),
876
+ // crew#661: plus every subsystem whose phases ARMED without a declared skill (`phaseSkillGaps`,
877
+ // one `skills.phase-skill` finding each) — the degraded runs' operator-facing record.
878
+ skills: withPhaseSkillGaps(runtime.skills?.health() ?? disabledSkillsHealth(), runtime.phaseSkills),
775
879
  // Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
776
880
  // letters — with a `governance.deadletter` finding the moment the outbox holds one.
777
881
  governance,
@@ -1293,6 +1397,23 @@ runtime = {}) {
1293
1397
  }
1294
1398
  try {
1295
1399
  const runId = await adapter.launchRun(input);
1400
+ // crew#641/#642 (item 5): when a run is promoted from a chat, record how many seats
1401
+ // participated and whether the chat was grounded — so an intent authored by a single
1402
+ // ungrounded model is distinguishable from one that survived a disagreement.
1403
+ let chatSeatCount;
1404
+ let chatGrounded;
1405
+ // Both stay ABSENT unless the chat actually resolves: a closed or unknown chatId must read as
1406
+ // "provenance unavailable", not as "promoted from an ungrounded zero-seat chat". A valid chat
1407
+ // always has at least one warm seat (`crew-api-types` ChatOpenResponse), so 0 is not a value
1408
+ // `chat_seat_count` can honestly hold.
1409
+ if (b.chatId !== undefined) {
1410
+ const promotedFrom = await adapter.chatSeats(b.chatId).catch(() => null);
1411
+ const scope = chatScopes.engineOf(b.chatId);
1412
+ if (promotedFrom !== null && promotedFrom.length > 0 && scope !== undefined) {
1413
+ chatSeatCount = promotedFrom.length;
1414
+ chatGrounded = scope.codeGraphDb != null;
1415
+ }
1416
+ }
1296
1417
  // Who launched it — the engine's LaunchOptions carries no actor field
1297
1418
  // (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
1298
1419
  // of record for run provenance (task #88).
@@ -1322,7 +1443,20 @@ runtime = {}) {
1322
1443
  // the group index (and a restarted daemon's hydrate) reads it back from here.
1323
1444
  ...(b.campaignId !== undefined ? { campaignId: b.campaignId } : {}),
1324
1445
  ...(b.groupLabel !== undefined ? { groupLabel: b.groupLabel } : {}),
1446
+ // crew#619: the chat↔run link is durable so the retention maps can be rehydrated after a
1447
+ // daemon restart (rehydration reads `run.launched` entries and keeps non-terminal entries).
1448
+ ...(b.chatId !== undefined ? { chatId: b.chatId } : {}),
1449
+ // crew#641/#642 (item 5): chat promotion provenance — seat count and grounding at launch.
1450
+ ...(chatSeatCount !== undefined ? { chatSeatCount } : {}),
1451
+ ...(chatGrounded !== undefined ? { chatGrounded } : {}),
1452
+ // crew#632: launch surface and caller identity — human-readable provenance on the audit trail.
1453
+ ...(b.channel !== undefined ? { channel: b.channel } : {}),
1454
+ ...(b.actor !== undefined ? { actor: b.actor } : {}),
1325
1455
  });
1456
+ // Stamp the index live so the field is served without waiting for a daemon restart.
1457
+ if (chatSeatCount !== undefined && chatGrounded !== undefined) {
1458
+ runTimingIndex.setChatPromotion(runId, chatSeatCount, chatGrounded);
1459
+ }
1326
1460
  if (b.retryOf !== undefined)
1327
1461
  retryIndex.set(runId, b.retryOf);
1328
1462
  if (revisesPr !== undefined)
@@ -1341,6 +1475,10 @@ runtime = {}) {
1341
1475
  // decision #6 replaces spoofable actor strings on the event surface.
1342
1476
  { project_id: b.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.run', runId, Date.now()));
1343
1477
  }
1478
+ // crew#619: retain the chat transcript for the run's lifetime so Continue-in-Build prefill
1479
+ // is always reproducible even if the chat is idle-reclaimed before the run finishes.
1480
+ if (b.chatId !== undefined)
1481
+ runtime.linkChatRun?.(b.chatId, runId);
1344
1482
  return reply.code(201).send({ runId });
1345
1483
  }
1346
1484
  catch (err) {
@@ -1797,6 +1935,12 @@ runtime = {}) {
1797
1935
  }
1798
1936
  const b = parsed.data;
1799
1937
  const chatId = b.chatId ?? randomUUID();
1938
+ const requestedRefs = [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])];
1939
+ // A named scope's SHAPE first (studio#323 R4, codex on #664): a body whose kind refuses its own
1940
+ // projectId/repoRefs is a 400 — never masked by the project lookup's 404/409 below.
1941
+ const shape = namedKindRefusal(b.scopeKind, b.projectId, [...new Set(requestedRefs)]);
1942
+ if (shape !== null)
1943
+ return reply.code(400).send({ error: shape.error });
1800
1944
  // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
1801
1945
  // attach against atomically (chats are an in-memory seat pool), so the route validates
1802
1946
  // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
@@ -1837,8 +1981,14 @@ runtime = {}) {
1837
1981
  const resolution = await resolveChatScope({
1838
1982
  chatId,
1839
1983
  ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
1840
- repoRefs: [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])],
1841
- }, { ...chatScopeDeps(adapter), scratchBase: chatScopes.base, log: (m) => req.log.warn(m) });
1984
+ repoRefs: requestedRefs,
1985
+ ...(b.scopeKind !== undefined ? { kind: b.scopeKind } : {}),
1986
+ }, {
1987
+ ...chatScopeDeps(adapter),
1988
+ scratchBase: chatScopes.base,
1989
+ log: (m) => req.log.warn(m),
1990
+ ...(runtime.entityCount !== undefined ? { entityCount: runtime.entityCount } : {}),
1991
+ });
1842
1992
  if (!resolution.ok) {
1843
1993
  chatScopes.release(chatId, token);
1844
1994
  return reply.code(resolution.status).send({
@@ -1866,7 +2016,10 @@ runtime = {}) {
1866
2016
  // here is NAMED on the response (`refused`) and in the thread (`chatSeatRefused`), so a
1867
2017
  // person can see why pi is missing. Explicit `clis` are passed through as asked; the engine
1868
2018
  // refuses per seat with its reason, which is copied into `refused` too.
1869
- const scoped = scope.kind !== 'none';
2019
+ // A chat is SCOPED — held to read-only roots — when it reads repositories by its kind. `none`
2020
+ // and `system` (studio#323 R4: the platform itself, no repository) read none, so they take the
2021
+ // unscoped admission and open on an engine that predates chat scope.
2022
+ const scoped = scope.kind !== 'none' && scope.kind !== 'system';
1870
2023
  const refused = [];
1871
2024
  // The standing roster, read ONCE: the default admission below and the engine-drop
1872
2025
  // attribution after `chatOpen` both consult it (F-A45-011).
@@ -2019,6 +2172,12 @@ runtime = {}) {
2019
2172
  error: `chat ${chatId} was closed while it was being opened; open it again`,
2020
2173
  });
2021
2174
  }
2175
+ // Register roots for path rewriting (crew#618): absolute host paths in seat replies are
2176
+ // rewritten to repo-relative form before being stored in the transcript.
2177
+ if (chatTranscripts !== undefined && scope.repos.length > 0) {
2178
+ const roots = scope.repos.map((r) => ({ absRoot: resolve(r.rootPath), name: r.name }));
2179
+ chatTranscripts.registerRoots(chatId, roots);
2180
+ }
2022
2181
  // The thread learns of every refused seat the way it learns of everything else — a frame
2023
2182
  // on /ws — AFTER the scope is published, so a reader never sees a refusal for a chat it
2024
2183
  // cannot yet look up.
@@ -2032,12 +2191,17 @@ runtime = {}) {
2032
2191
  ...(b.projectId !== undefined ? { project_id: b.projectId } : {}),
2033
2192
  });
2034
2193
  }
2194
+ // crew#641: a chat with exactly ONE warm seat cannot disagree with itself — disclosed
2195
+ // prominently so the caller does not have to reason about the refused[] array.
2196
+ const warm = seats.filter((s) => s.ok);
2197
+ const singleSeat = warm.length === 1 ? singleSeatDisclosure(warm[0].cliKey, refused, 'open') : undefined;
2035
2198
  return reply.code(201).send({
2036
2199
  chatId,
2037
2200
  seats,
2038
2201
  scope,
2039
2202
  refused,
2040
2203
  ...(projectAttachError !== undefined ? { projectAttachError } : {}),
2204
+ ...(singleSeat !== undefined ? { singleSeat } : {}),
2041
2205
  });
2042
2206
  }
2043
2207
  catch (err) {
@@ -2088,7 +2252,12 @@ runtime = {}) {
2088
2252
  // warm seats — so the turn can be opened BEFORE the engine call, in the same tick as the
2089
2253
  // predicate below: two racing sends can no longer both pass `inFlight` during the await, and a
2090
2254
  // `chatDelta` that lands before `chatSend` resolves is already stamped with its `turn_id`.
2091
- const audience = parsed.data.targets ?? (await adapter.chatSeats(id));
2255
+ // crew#641 (review): the ROSTER and the AUDIENCE are different things and only one of them can
2256
+ // answer "can this chat disagree with itself". Resolved ONCE here: `audience` is who this turn
2257
+ // goes to (the named targets, else everyone warm), `warmRoster` is who is warm at all. A
2258
+ // targeted send to one seat of a two-seat chat is not a degraded chat.
2259
+ const warmRoster = await adapter.chatSeats(id);
2260
+ const audience = parsed.data.targets ?? warmRoster;
2092
2261
  if (audience.length === 0) {
2093
2262
  return reply.code(409).send({ error: `chat '${id}' has no warm seats — open it first` });
2094
2263
  }
@@ -2106,7 +2275,22 @@ runtime = {}) {
2106
2275
  chatTurns.reconcile(id, turn.turnId, seats);
2107
2276
  chatTranscripts?.appendUser(id, turn.turnId, parsed.data.text, seats);
2108
2277
  }
2109
- return reply.code(202).send({ seats, ...(turn !== null ? { turnId: turn.turnId } : {}) });
2278
+ // crew#641: re-state single-seat degradation on every turn so it is visible in the transcript.
2279
+ // Decided from `warmRoster` — the seats that are WARM — never from `seats`, which is only the
2280
+ // seats this turn reached: a targeted send to one seat of a two-seat chat would otherwise
2281
+ // announce a degradation that does not exist, and a false "you are degraded" sends an operator
2282
+ // after a problem they do not have.
2283
+ // crew#650: `refusedOf` is `undefined` for a chat THIS daemon did not open (a restart drops
2284
+ // the index) — that is "the refusals are unknown", not "the chat gained a seat". The
2285
+ // disclosure is decided by the warm roster alone, and the `undefined` is handed STRAIGHT to
2286
+ // the builder (never `?? []`, review of #658): only it may decide what an unknown record is
2287
+ // allowed to claim.
2288
+ const singleSeat202 = warmRoster.length === 1 ? singleSeatDisclosure(warmRoster[0], chatScopes.refusedOf(id), 'turn') : undefined;
2289
+ return reply.code(202).send({
2290
+ seats,
2291
+ ...(turn !== null ? { turnId: turn.turnId } : {}),
2292
+ ...(singleSeat202 !== undefined ? { singleSeat: singleSeat202 } : {}),
2293
+ });
2110
2294
  }
2111
2295
  catch (err) {
2112
2296
  // Nothing went out: the reservation is retracted so the next send is not refused for it.
@@ -4136,6 +4320,8 @@ runtime = {}) {
4136
4320
  settings: projectSettings,
4137
4321
  pool: interactiveBridges,
4138
4322
  ...(runtime.docGrounding !== undefined ? { grounding: runtime.docGrounding } : {}),
4323
+ // crew#631: supply the standing roster so doc/demo creates can refuse an unavailable seat.
4324
+ roster: () => rosterWithStanding(),
4139
4325
  log: (m) => app.log.warn(m),
4140
4326
  });
4141
4327
  // ── Governed doc delete (crew#338) — the one door that changes BOTH stores ──