@flowdular/sandbox 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /* The active-questions protocol. A specialist that needs a decision from the
2
+ operator closes its reply with one fenced block tagged `questions`; this
3
+ module is the only place that turns that text into data, so a malformed block
4
+ is a turn warning instead of raw JSON the operator has to read. Nothing here
5
+ touches the file system or the network: the dashboard imports the same
6
+ parser, so the transcript and the server never disagree about a block. */
7
+
8
+ export interface PendingQuestion {
9
+ readonly id: string;
10
+ readonly question: string;
11
+ readonly options: readonly string[];
12
+ /* One of `options`. Absent when the specialist recommends nothing. */
13
+ readonly recommended?: string;
14
+ readonly allowFreeText: boolean;
15
+ }
16
+
17
+ /* What the session carries between the turn that asked and the answers that
18
+ start the next one: the questions, who asked them, and where. */
19
+ export interface PendingQuestions {
20
+ /* The transcript sequence of the agent message the block came from. */
21
+ readonly sequence: number;
22
+ readonly role: string;
23
+ readonly module?: string;
24
+ readonly askedAt: number;
25
+ readonly questions: readonly PendingQuestion[];
26
+ }
27
+
28
+ export const MAX_QUESTIONS = 12;
29
+ export const MAX_OPTIONS = 8;
30
+ export const MAX_QUESTION_LENGTH = 400;
31
+ export const MAX_OPTION_LENGTH = 120;
32
+ export const MAX_ANSWER_LENGTH = 400;
33
+ /* The block is a small decision list, not a document: a larger one is refused
34
+ before JSON.parse allocates it. */
35
+ export const MAX_BLOCK_LENGTH = 8_000;
36
+
37
+ const QUESTION_ID = /^Q-[0-9]+$/;
38
+ /* A question, an option and an answer each become one line of the decisions the
39
+ answered turn reads back. A line break or a control character inside one would
40
+ let the text that wrote it forge further decision lines, so the value is
41
+ refused whole rather than repaired. */
42
+ const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
43
+ const FENCE = /^[ \t]*```[ \t]*([A-Za-z0-9_-]*)[ \t]*$/;
44
+ /* Every role closes with a handoff line, so it may follow the block. */
45
+ const HANDOFF_LINE = /^[ \t]*(?:[`*_]*)handoff[ \t]*:/i;
46
+
47
+ export interface QuestionsBlock {
48
+ readonly raw: string;
49
+ /* The reply without the block, which is what the transcript speaks. */
50
+ readonly remainder: string;
51
+ }
52
+
53
+ export type QuestionsReading =
54
+ | { readonly kind: 'none' }
55
+ | {
56
+ readonly kind: 'valid';
57
+ readonly questions: readonly PendingQuestion[];
58
+ readonly remainder: string;
59
+ }
60
+ | {
61
+ readonly kind: 'invalid';
62
+ readonly reason: string;
63
+ readonly remainder: string;
64
+ };
65
+
66
+ interface FencedBlock {
67
+ readonly tag: string;
68
+ readonly open: number;
69
+ readonly close: number;
70
+ }
71
+
72
+ /* One pass over the lines, pairing fences in order: an opening fence carries
73
+ the info string, the next fence closes it. An unterminated fence is not a
74
+ block, so half-streamed output never parses as one. */
75
+ function fencedBlocks(lines: readonly string[]): readonly FencedBlock[] {
76
+ const blocks: FencedBlock[] = [];
77
+ let open = -1;
78
+ let tag = '';
79
+ for (let index = 0; index < lines.length; index += 1) {
80
+ const match = FENCE.exec(lines[index]!);
81
+ if (!match) continue;
82
+ if (open < 0) {
83
+ open = index;
84
+ tag = match[1] ?? '';
85
+ continue;
86
+ }
87
+ blocks.push({ tag, open, close: index });
88
+ open = -1;
89
+ tag = '';
90
+ }
91
+ return blocks;
92
+ }
93
+
94
+ /* The block the protocol recognises: tagged `questions`, the last thing in the
95
+ reply apart from the mandatory handoff line. A block in the middle of an
96
+ explanation is prose about the protocol, not an instance of it. */
97
+ export function findQuestionsBlock(
98
+ text: string,
99
+ ): QuestionsBlock | { readonly reason: string } | null {
100
+ if (!text.includes('```')) return null;
101
+ const lines = text.split('\n');
102
+ const tagged = fencedBlocks(lines).filter(
103
+ (block) => block.tag.toLowerCase() === 'questions',
104
+ );
105
+ if (tagged.length === 0) return null;
106
+ if (tagged.length > 1) {
107
+ return { reason: 'A reply carries at most one questions block.' };
108
+ }
109
+ const block = tagged[0]!;
110
+ for (let index = block.close + 1; index < lines.length; index += 1) {
111
+ const line = lines[index]!;
112
+ if (!line.trim() || HANDOFF_LINE.test(line)) continue;
113
+ return {
114
+ reason: 'The questions block must be the last thing in the reply.',
115
+ };
116
+ }
117
+ const before = lines.slice(0, block.open);
118
+ const after = lines.slice(block.close + 1);
119
+ return {
120
+ raw: lines.slice(block.open + 1, block.close).join('\n'),
121
+ remainder: [...before, ...after].join('\n').trim(),
122
+ };
123
+ }
124
+
125
+ function invalid(reason: string): {
126
+ readonly ok: false;
127
+ readonly reason: string;
128
+ } {
129
+ return { ok: false, reason };
130
+ }
131
+
132
+ export type QuestionsParse =
133
+ | { readonly ok: true; readonly questions: readonly PendingQuestion[] }
134
+ | { readonly ok: false; readonly reason: string };
135
+
136
+ /* Every bound is checked before the value is kept, so a session record can only
137
+ ever hold a list the answers form and the answers route both accept. */
138
+ export function parseQuestionsBlock(raw: string): QuestionsParse {
139
+ if (raw.length > MAX_BLOCK_LENGTH) {
140
+ return invalid(
141
+ `A questions block is limited to ${MAX_BLOCK_LENGTH} characters.`,
142
+ );
143
+ }
144
+ let value: unknown;
145
+ try {
146
+ value = JSON.parse(raw) as unknown;
147
+ } catch {
148
+ return invalid('The questions block is not valid JSON.');
149
+ }
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
151
+ return invalid('The questions block must be a JSON object.');
152
+ }
153
+ const list = (value as { questions?: unknown }).questions;
154
+ if (!Array.isArray(list) || list.length === 0) {
155
+ return invalid('The questions block needs a non-empty questions array.');
156
+ }
157
+ if (list.length > MAX_QUESTIONS) {
158
+ return invalid(
159
+ `A questions block asks at most ${MAX_QUESTIONS} questions.`,
160
+ );
161
+ }
162
+ const questions: PendingQuestion[] = [];
163
+ const seen = new Set<string>();
164
+ for (const entry of list as readonly unknown[]) {
165
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
166
+ return invalid('Every question must be a JSON object.');
167
+ }
168
+ const item = entry as Record<string, unknown>;
169
+ const id = item.id;
170
+ if (typeof id !== 'string' || !QUESTION_ID.test(id)) {
171
+ return invalid('Every question id must look like Q-1.');
172
+ }
173
+ if (seen.has(id)) return invalid(`Question id ${id} is used twice.`);
174
+ seen.add(id);
175
+ const question = item.question;
176
+ if (
177
+ typeof question !== 'string' ||
178
+ question.trim().length === 0 ||
179
+ question.length > MAX_QUESTION_LENGTH
180
+ ) {
181
+ return invalid(
182
+ `${id} must ask a question of 1 to ${MAX_QUESTION_LENGTH} characters.`,
183
+ );
184
+ }
185
+ if (CONTROL_CHARACTER.test(question.trim())) {
186
+ return invalid(
187
+ `${id} may not contain a line break or a control character.`,
188
+ );
189
+ }
190
+ const rawOptions = item.options ?? [];
191
+ if (!Array.isArray(rawOptions) || rawOptions.length > MAX_OPTIONS) {
192
+ return invalid(`${id} may offer at most ${MAX_OPTIONS} options.`);
193
+ }
194
+ const options: string[] = [];
195
+ for (const option of rawOptions as readonly unknown[]) {
196
+ if (
197
+ typeof option !== 'string' ||
198
+ option.trim().length === 0 ||
199
+ option.length > MAX_OPTION_LENGTH
200
+ ) {
201
+ return invalid(
202
+ `Every option of ${id} must be 1 to ${MAX_OPTION_LENGTH} characters.`,
203
+ );
204
+ }
205
+ if (CONTROL_CHARACTER.test(option.trim())) {
206
+ return invalid(
207
+ `No option of ${id} may contain a line break or a control character.`,
208
+ );
209
+ }
210
+ if (options.includes(option.trim())) {
211
+ return invalid(`${id} repeats the option ${option.trim()}.`);
212
+ }
213
+ options.push(option.trim());
214
+ }
215
+ const allowFreeText = item.allowFreeText ?? false;
216
+ if (typeof allowFreeText !== 'boolean') {
217
+ return invalid(`allowFreeText of ${id} must be true or false.`);
218
+ }
219
+ if (options.length === 0 && !allowFreeText) {
220
+ return invalid(
221
+ `${id} offers no option and no free text, so it cannot be answered.`,
222
+ );
223
+ }
224
+ const recommended = item.recommended;
225
+ if (recommended !== undefined && recommended !== null) {
226
+ if (
227
+ typeof recommended !== 'string' ||
228
+ !options.includes(recommended.trim())
229
+ ) {
230
+ return invalid(
231
+ `The recommendation of ${id} must be one of its options.`,
232
+ );
233
+ }
234
+ }
235
+ questions.push({
236
+ id,
237
+ question: question.trim(),
238
+ options,
239
+ ...(typeof recommended === 'string' &&
240
+ options.includes(recommended.trim())
241
+ ? { recommended: recommended.trim() }
242
+ : {}),
243
+ allowFreeText,
244
+ });
245
+ }
246
+ return { ok: true, questions };
247
+ }
248
+
249
+ /* The whole reading of one agent message: whether it asked, what it asked, and
250
+ what is left to show as speech. */
251
+ export function readQuestions(text: string): QuestionsReading {
252
+ const found = findQuestionsBlock(text);
253
+ if (!found) return { kind: 'none' };
254
+ if ('reason' in found) {
255
+ return { kind: 'invalid', reason: found.reason, remainder: text };
256
+ }
257
+ const parsed = parseQuestionsBlock(found.raw);
258
+ if (!parsed.ok) {
259
+ return {
260
+ kind: 'invalid',
261
+ reason: parsed.reason,
262
+ remainder: found.remainder,
263
+ };
264
+ }
265
+ return {
266
+ kind: 'valid',
267
+ questions: parsed.questions,
268
+ remainder: found.remainder,
269
+ };
270
+ }
271
+
272
+ export interface AnswerDecision {
273
+ readonly id: string;
274
+ readonly question: string;
275
+ readonly answer: string;
276
+ }
277
+
278
+ export type AnswersParse =
279
+ | { readonly ok: true; readonly decisions: readonly AnswerDecision[] }
280
+ | { readonly ok: false; readonly reason: string };
281
+
282
+ /* The answers a request carries, checked against the questions this session
283
+ actually asked: every pending question is answered exactly once, and an
284
+ answer that is not free text must be one of the offered options. */
285
+ export function resolveAnswers(
286
+ pending: PendingQuestions,
287
+ value: unknown,
288
+ ): AnswersParse {
289
+ if (!Array.isArray(value)) {
290
+ return invalid('answers must be an array.');
291
+ }
292
+ if (value.length !== pending.questions.length) {
293
+ return invalid(
294
+ `This session is waiting for ${pending.questions.length} answers.`,
295
+ );
296
+ }
297
+ const byId = new Map(pending.questions.map((entry) => [entry.id, entry]));
298
+ const decisions: AnswerDecision[] = [];
299
+ for (const entry of value as readonly unknown[]) {
300
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
301
+ return invalid('Every answer must be a JSON object.');
302
+ }
303
+ const item = entry as Record<string, unknown>;
304
+ const id = item.id;
305
+ if (typeof id !== 'string' || !byId.has(id)) {
306
+ return invalid('Every answer must name a pending question.');
307
+ }
308
+ const question = byId.get(id)!;
309
+ byId.delete(id);
310
+ /* A record written before the parser refused these still has to stay out
311
+ of the decisions text. */
312
+ if (CONTROL_CHARACTER.test(question.question)) {
313
+ return invalid(
314
+ `${id} carries a line break or a control character and cannot be answered.`,
315
+ );
316
+ }
317
+ const answer = item.answer;
318
+ if (
319
+ typeof answer !== 'string' ||
320
+ answer.trim().length === 0 ||
321
+ answer.length > MAX_ANSWER_LENGTH
322
+ ) {
323
+ return invalid(
324
+ `The answer to ${id} must be 1 to ${MAX_ANSWER_LENGTH} characters.`,
325
+ );
326
+ }
327
+ const trimmed = answer.trim();
328
+ if (CONTROL_CHARACTER.test(trimmed)) {
329
+ return invalid(
330
+ `The answer to ${id} may not contain a line break or a control character.`,
331
+ );
332
+ }
333
+ if (
334
+ !question.allowFreeText &&
335
+ question.options.length > 0 &&
336
+ !question.options.includes(trimmed)
337
+ ) {
338
+ return invalid(`The answer to ${id} must be one of its options.`);
339
+ }
340
+ decisions.push({ id, question: question.question, answer: trimmed });
341
+ }
342
+ return { ok: true, decisions };
343
+ }
344
+
345
+ /* The request text the answered turn starts from. The specialist reads its own
346
+ questions back with the decision beside each one, so it never has to guess
347
+ which answer belongs to which question. */
348
+ export function formatDecisions(decisions: readonly AnswerDecision[]): string {
349
+ return [
350
+ 'Decisions:',
351
+ ...decisions.map(
352
+ (decision) =>
353
+ `- ${decision.id}: ${decision.question} -> ${decision.answer}`,
354
+ ),
355
+ ].join('\n');
356
+ }
@@ -36,6 +36,9 @@ export async function referenceSource(
36
36
  const REFERENCE_SOURCES: readonly {
37
37
  readonly from: string;
38
38
  readonly to: string;
39
+ /* A source whose absence is a defect rather than a shape the workspace may
40
+ legitimately have, so a failed copy is reported instead of swallowed. */
41
+ readonly required?: boolean;
39
42
  }[] = [
40
43
  { from: 'packages/server/src', to: 'reference/packages/server/src' },
41
44
  {
@@ -54,6 +57,14 @@ const REFERENCE_SOURCES: readonly {
54
57
  from: 'packages/contracts/schemas',
55
58
  to: 'reference/packages/contracts/schemas',
56
59
  },
60
+ {
61
+ from: 'packages/kernel/src/data-class-registry.ts',
62
+ to: 'reference/packages/kernel/data-class-registry.ts',
63
+ },
64
+ {
65
+ from: 'packages/storage/src/index.ts',
66
+ to: 'reference/packages/storage/index.ts',
67
+ },
57
68
  { from: 'packages/ui/src/index.ts', to: 'reference/packages/ui/index.ts' },
58
69
  {
59
70
  from: 'packages/ui/src/components',
@@ -89,6 +100,11 @@ const REFERENCE_SOURCES: readonly {
89
100
  to: 'reference/auth-core/auth-service.ts',
90
101
  },
91
102
  { from: 'AGENTS.md', to: 'reference/AGENTS.md' },
103
+ {
104
+ from: '.ai/platform-capabilities.md',
105
+ to: 'reference/platform-capabilities.md',
106
+ required: true,
107
+ },
92
108
  { from: 'docs/design-system.md', to: 'reference/design-system.md' },
93
109
  { from: 'docs/agent-contract.md', to: 'reference/agent-contract.md' },
94
110
  { from: '.ai/skills', to: 'reference/skills' },
@@ -109,10 +125,13 @@ not ejected.
109
125
  - packages/server: defineEndpoint, HTTP helpers, and the endpoint identity contract.
110
126
  - packages/client: the client contribution contract (createClientContribution, ModuleClientContext), shell slots, and shell state.
111
127
  - packages/contracts: module manifest, spec, and blueprint schemas.
128
+ - packages/kernel/data-class-registry.ts: the data class declaration a module owning rows makes through context.dataClasses, with its sweep and export operations.
129
+ - packages/storage/index.ts: the object storage port a module writes files through.
112
130
  - packages/ui: every shared primitive and the ui-* class list.
113
131
  - example-module: a complete module, from ACL to client view, including src/platform.ts. Follow its shape.
114
132
  - adapter-module: the same shape on the @flowdular/sdk/database provider contract, with an async repository, dialect-explicit migrations and a lease-owning runtime. Follow it when the module stores data.
115
133
  - auth-core: the public surface of auth.core, including its scopes, the PlatformServerContext composition contract, and its service API.
134
+ - platform-capabilities.md: what a module can be built from and what the platform does not have yet. Read it before a specification promises anything.
116
135
  - AGENTS.md and design-system.md: the workspace rules that gates enforce.
117
136
  - agent-contract.md: detailed lookup reference, not required reading.
118
137
  - skills: read only the Task skill named in your Session instruction. Other files are available for later tasks, not for preloading.
@@ -166,7 +185,14 @@ export async function materializeReference(
166
185
  !relative(from, path)
167
186
  .split('/')
168
187
  .some((segment) => EXCLUDED.has(segment)),
169
- }).catch(() => undefined);
188
+ }).catch((error: unknown) => {
189
+ if (!source.required) return;
190
+ console.warn(
191
+ `Sandbox reference: ${source.from} was not copied into the session workspace (${
192
+ error instanceof Error ? error.message : String(error)
193
+ }). A skill that cites it will not find it.`,
194
+ );
195
+ });
170
196
  }
171
197
  await materializeSdkReference(workspaceRoot, sessionWorkspace);
172
198
  const skills = await listSkills(workspaceRoot);
@@ -78,6 +78,7 @@ import {
78
78
  specPathOf,
79
79
  type ModuleSpecReview,
80
80
  } from './spec.ts';
81
+ import { formatDecisions, resolveAnswers } from './questions.ts';
81
82
  import type { BrowserSession, SandboxRuntime } from './runtime.ts';
82
83
  import { PlatformClient } from './platform-client.ts';
83
84
  import { SandboxSetupError } from './workspace-root.ts';
@@ -131,6 +132,7 @@ const STATUS_BY_CODE: Readonly<Record<string, number>> = {
131
132
  MODULE_ALREADY_IN_SESSION: 409,
132
133
  SPEC_NOT_FOUND: 404,
133
134
  SPEC_NOT_APPROVED: 409,
135
+ NO_PENDING_QUESTIONS: 409,
134
136
  EJECT_SPEC_MISSING: 409,
135
137
  EJECT_SPEC_NOT_APPROVED: 409,
136
138
  EJECT_SPEC_VERSION_UNCHANGED: 409,
@@ -143,6 +145,9 @@ const STATUS_BY_CODE: Readonly<Record<string, number>> = {
143
145
  const MAX_SPEC_TEXT = 200_000;
144
146
  const MAX_SPEC_COMMENT = 4_000;
145
147
  const MAX_JSON_BODY_BYTES = 256_000;
148
+ /* What an operator may add beside the decisions. The decision list is bounded
149
+ by the questions protocol, so the two together stay inside a turn message. */
150
+ const MAX_ANSWER_NOTE = 8_000;
146
151
 
147
152
  export interface SandboxRouteOptions {
148
153
  /* The port the launcher bound. A loopback request must name it in its Host
@@ -626,6 +631,14 @@ function assertNotDelivered(session: SandboxSession): void {
626
631
  }
627
632
  }
628
633
 
634
+ /* The turn one accepted answer set starts: the decisions the specialist reads
635
+ back, in the role and the module that asked for them. */
636
+ interface AnsweredTurn {
637
+ readonly message: string;
638
+ readonly role: string;
639
+ readonly module?: string;
640
+ }
641
+
629
642
  /* A turn, or a chain of automatically continued turns, runs to completion on
630
643
  the server whatever happens to the browser. Streams subscribe to it and can
631
644
  leave at any time; stopping is an explicit action. The finished promise is
@@ -666,6 +679,7 @@ export function createSandboxRoutes(
666
679
  sessionId: string,
667
680
  input: {
668
681
  message: string;
682
+ skillTask?: string;
669
683
  freshContext?: boolean;
670
684
  role?: string;
671
685
  module?: string;
@@ -701,6 +715,7 @@ export function createSandboxRoutes(
701
715
  });
702
716
  let next: {
703
717
  message: string;
718
+ skillTask?: string;
704
719
  role?: string;
705
720
  module?: string;
706
721
  driver?: string;
@@ -1546,6 +1561,60 @@ export function createSandboxRoutes(
1546
1561
  },
1547
1562
  });
1548
1563
 
1564
+ /* Answering the questions the last turn asked is a turn of its own: the
1565
+ decisions lead the request text, the operator's own words follow them, and
1566
+ the specialist that asked takes the turn in the module it asked about. */
1567
+ const answerQuestions = new ServerRoute({
1568
+ path: '/sandbox/api/sessions/:id/answers',
1569
+ methods: ['POST'],
1570
+ handler: async (context) => {
1571
+ try {
1572
+ await authorize(runtime, context, options, { mutation: true });
1573
+ const sessionId = sessionIdParam(context);
1574
+ const value = await body(context.request);
1575
+ const note = optionalText(value, 'message', MAX_ANSWER_NOTE);
1576
+ let answered: AnsweredTurn | null = null;
1577
+ /* Reading the questions, resolving them and clearing them is one
1578
+ step under the session lock: two submissions arriving together
1579
+ must not both find the same decisions pending and start a turn
1580
+ from them. */
1581
+ await updateSession(runtime.workspaceRoot, sessionId, (current) => {
1582
+ assertNotArchived(current);
1583
+ assertNotDelivered(current);
1584
+ const pending = current.pendingQuestions;
1585
+ if (!pending || pending.questions.length === 0) {
1586
+ throw new SandboxSetupError(
1587
+ 'NO_PENDING_QUESTIONS',
1588
+ 'This session is not waiting for a decision.',
1589
+ );
1590
+ }
1591
+ const resolved = resolveAnswers(pending, value.answers);
1592
+ if (!resolved.ok) {
1593
+ throw new SandboxSetupError('INVALID_INPUT', resolved.reason);
1594
+ }
1595
+ const decisions = formatDecisions(resolved.decisions);
1596
+ answered = {
1597
+ message: note ? `${decisions}\n\n${note}` : decisions,
1598
+ role: pending.role,
1599
+ ...(pending.module ? { module: pending.module } : {}),
1600
+ };
1601
+ return { pendingQuestions: null };
1602
+ });
1603
+ const turn: AnsweredTurn = answered!;
1604
+ const channel = startTurn(
1605
+ sessionId,
1606
+ /* The decisions text is the specialist's own words read back, so
1607
+ only the operator's note may name a skill for this turn. */
1608
+ { ...turn, skillTask: note ?? '' },
1609
+ actingPlatform(runtime, context),
1610
+ );
1611
+ return streamChannel(channel);
1612
+ } catch (error) {
1613
+ return failure(error);
1614
+ }
1615
+ },
1616
+ });
1617
+
1549
1618
  /* Follow a turn another browser, or an earlier page load, started. */
1550
1619
  const followTurn = new ServerRoute({
1551
1620
  path: '/sandbox/api/sessions/:id/turn/stream',
@@ -2232,6 +2301,7 @@ export function createSandboxRoutes(
2232
2301
  restoreSandboxCheckpoint,
2233
2302
  removeSandboxSession,
2234
2303
  turn,
2304
+ answerQuestions,
2235
2305
  followTurn,
2236
2306
  stop,
2237
2307
  settings,
@@ -15,6 +15,7 @@ import {
15
15
  import { isAbsolute, join, relative, resolve } from 'node:path';
16
16
  import type { CodingAgentEvent } from '#coding-agent';
17
17
  import { sandboxDirectory } from './config.ts';
18
+ import type { PendingQuestions } from './questions.ts';
18
19
  import { materializeModuleGraph, materializeReference } from './reference.ts';
19
20
  import { hashSpec } from './spec.ts';
20
21
  import { forgetDiffs } from './turns.ts';
@@ -121,6 +122,10 @@ export interface SandboxSession {
121
122
  /* Restore points, oldest first. Bounded; see checkpoints.ts for the cap and
122
123
  the pruning that never drops the start. */
123
124
  readonly checkpoints: readonly SessionCheckpoint[];
125
+ /* The decisions the last turn asked the operator for, or null when it asked
126
+ for none. Every turn rewrites it, so the form can only ever show what the
127
+ newest specialist is still waiting on. */
128
+ readonly pendingQuestions: PendingQuestions | null;
124
129
  readonly state: SandboxSessionState;
125
130
  readonly createdAt: number;
126
131
  readonly updatedAt: number;
@@ -456,6 +461,7 @@ export async function createSession(
456
461
  checkpoints: [
457
462
  { sequence: 0, at: now, label: 'the starting point', role: input.role },
458
463
  ],
464
+ pendingQuestions: null,
459
465
  state: 'draft',
460
466
  createdAt: now,
461
467
  updatedAt: now,
@@ -551,6 +557,7 @@ export async function readSession(
551
557
  chainDepth: record.chainDepth ?? 0,
552
558
  attachments: record.attachments ?? [],
553
559
  checkpoints: record.checkpoints ?? [],
560
+ pendingQuestions: record.pendingQuestions ?? null,
554
561
  ejectedAt: record.ejectedAt ?? null,
555
562
  archivedAt: record.archivedAt ?? null,
556
563
  };
@@ -53,6 +53,7 @@ import {
53
53
  type RoutingContext,
54
54
  } from './planning.ts';
55
55
  import type { PlatformClient } from './platform-client.ts';
56
+ import { readQuestions } from './questions.ts';
56
57
  import { listSkills, writeAgentPointer } from './reference.ts';
57
58
  import {
58
59
  appendChatEntry,
@@ -97,6 +98,10 @@ export interface TurnInput {
97
98
  readonly freshContext?: boolean;
98
99
  readonly sessionId: string;
99
100
  readonly message: string;
101
+ /* The operator's own words, when the request text also carries text a
102
+ specialist wrote. Only this is scanned for an explicit $skill, so a
103
+ question can never choose the skill of the turn that answers it. */
104
+ readonly skillTask?: string;
100
105
  readonly role?: string;
101
106
  /* The draft module directory this turn works in. Defaults to the module the
102
107
  last handoff named, then to the primary module. */
@@ -646,7 +651,14 @@ export async function* runTurn(
646
651
  sessionKind: active.kind === 'new' ? 'new-module' : 'edit-module',
647
652
  blueprint: session.blueprint,
648
653
  task: message,
654
+ ...(input.skillTask === undefined
655
+ ? {}
656
+ : { explicitSkillSource: input.skillTask }),
649
657
  available: await listSkills(context.workspaceRoot),
658
+ /* The gate of the module this turn works in, read before the driver ran:
659
+ until the operator approved that exact text there is nothing to
660
+ implement from, so a new module starts as an interview. */
661
+ specApproved: gate.approved === true,
650
662
  });
651
663
  const reviewing = skill === 'auto-review';
652
664
  if (reviewing) await prepareAutoReview(context.workspaceRoot, paths, active);
@@ -736,6 +748,9 @@ export async function* runTurn(
736
748
  let nextResumeId = resumeId;
737
749
  let failed = false;
738
750
  let closing = '';
751
+ /* The transcript entry the closing message landed on, so a questions block
752
+ the operator answers stays tied to the message that asked. */
753
+ let closingSequence = 0;
739
754
  /* This snapshot is the enforcement point for role ownership. The workspace
740
755
  remains readable to the agent, but changes outside its module allowlist are
741
756
  quarantined and restored before any formatter, gate, checkpoint or delivery
@@ -758,14 +773,18 @@ export async function* runTurn(
758
773
  model: session.model,
759
774
  signal: input.signal,
760
775
  })) {
761
- yield await appendChatEntry(context.workspaceRoot, session, {
776
+ const recorded = await appendChatEntry(context.workspaceRoot, session, {
762
777
  kind: eventKind(event),
763
778
  role: roleId,
764
779
  module: active.directory,
765
780
  ...(event.type === 'assistant.message' ? { text: event.text } : {}),
766
781
  event,
767
782
  });
768
- if (event.type === 'assistant.message') closing = event.text;
783
+ yield recorded;
784
+ if (event.type === 'assistant.message') {
785
+ closing = event.text;
786
+ closingSequence = recorded.sequence;
787
+ }
769
788
  if (event.type === 'turn.completed') {
770
789
  nextResumeId = event.resumeId ?? nextResumeId;
771
790
  failed = event.finishReason === 'error';
@@ -809,6 +828,19 @@ export async function* runTurn(
809
828
  });
810
829
  }
811
830
 
831
+ /* A specialist that needs decisions closes with a questions block. A block
832
+ the protocol cannot read is a warning on this turn, never a failure: the
833
+ words of the reply still stand. */
834
+ const asked = closing ? readQuestions(closing) : ({ kind: 'none' } as const);
835
+ if (asked.kind === 'invalid') {
836
+ yield await appendChatEntry(context.workspaceRoot, session, {
837
+ kind: 'system',
838
+ role: roleId,
839
+ module: active.directory,
840
+ text: `The questions block in this reply was ignored: ${asked.reason}`,
841
+ });
842
+ }
843
+
812
844
  const scaffoldNote =
813
845
  failed || reviewing ? null : await scaffoldFromSpec(context, session);
814
846
  if (scaffoldNote) {
@@ -1013,6 +1045,16 @@ export async function* runTurn(
1013
1045
  if (nextResumeId) resumeIds[resumeKey] = nextResumeId;
1014
1046
  const updated = await updateSession(context.workspaceRoot, session.id, {
1015
1047
  resumeIds,
1048
+ pendingQuestions:
1049
+ asked.kind === 'valid' && !failed
1050
+ ? {
1051
+ sequence: closingSequence,
1052
+ role: roleId,
1053
+ module: active.directory,
1054
+ askedAt: Date.now(),
1055
+ questions: asked.questions,
1056
+ }
1057
+ : null,
1016
1058
  state: failed
1017
1059
  ? 'failed'
1018
1060
  : handoff.kind === 'approval' || handoff.kind === 'question'