@flowdular/sandbox 0.2.8 → 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.
package/README.md CHANGED
@@ -459,6 +459,31 @@ anyone else works, so a broken change never travels down the chain. The fix
459
459
  prompt carries the gate command and the first 4000 characters of its output;
460
460
  the transcript keeps the whole output (head and tail of a long one).
461
461
 
462
+ ### Active questions
463
+
464
+ A specialist that needs a business decision closes its reply with one fenced
465
+ block tagged `questions` holding
466
+ `{"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]}`.
467
+ The sandbox parses and bounds it (at most 12 questions with unique `Q-n` ids, a
468
+ question of 1 to 400 characters, at most 8 options of 1 to 120 characters, a
469
+ recommendation that is one of them) and stores it on the session as
470
+ `pendingQuestions`; a block it cannot read is a turn warning, not a failed turn.
471
+ The transcript lists the questions instead of showing JSON, and the session view
472
+ offers a form with the recommendation preselected.
473
+
474
+ ```
475
+ POST /sandbox/api/sessions/:id/answers
476
+ { "answers": [{ "id": "Q-1", "answer": "Only the owner" }], "message": "optional" }
477
+ ```
478
+
479
+ Behind the same boundary as every other mutation. It clears the questions and
480
+ starts the next turn in the role that asked, in the module it asked about, with
481
+ `Decisions:` and one `- Q-1: <question> -> <answer>` line per decision leading
482
+ the operator's optional message. Refusals: `409 NO_PENDING_QUESTIONS`, `409
483
+ SESSION_ARCHIVED`, `409 SESSION_DELIVERED`, and `400 INVALID_INPUT` for an
484
+ unanswered question, an unknown id, an over-long answer, or an answer that is
485
+ not one of the offered options when no free text was allowed.
486
+
462
487
  ## Turn lifetime
463
488
 
464
489
  A turn runs to completion on the server whatever happens to the browser: the
@@ -66,6 +66,40 @@ import { createServer } from "vite";
66
66
 
67
67
  // ../dev-console/src/index.mjs
68
68
  import { createLogger } from "vite";
69
+
70
+ // ../dev-console/src/brand.mjs
71
+ var MARK = [
72
+ " XXX XXX",
73
+ " XXX XXX",
74
+ " XXXXXXXXXXXXXXXXX",
75
+ "XXXXXXXXXXXXXXXXXXX",
76
+ " XXX XXX",
77
+ "XXXXXXXXXXXXXXXXXXX",
78
+ " XXXXXXXXXXXXXXXXX",
79
+ " XXX XXX",
80
+ " XXX XXX"
81
+ ];
82
+ function renderBrandHeader({
83
+ title = "FLOWDULAR",
84
+ subtitle = "",
85
+ color = false,
86
+ columns = process.stdout.columns ?? 80,
87
+ terminal = Boolean(process.stdout.isTTY)
88
+ } = {}) {
89
+ const brand = (text) => color ? `\x1B[1;38;5;42m${text}\x1B[0m` : text;
90
+ const muted = (text) => color ? `\x1B[90m${text}\x1B[0m` : text;
91
+ if (!terminal) {
92
+ return ` ${brand(title)}${subtitle ? ` ${muted(subtitle)}` : ""}`;
93
+ }
94
+ const heading = [
95
+ ` ${brand(title)}`,
96
+ ...subtitle ? [` ${muted(subtitle)}`] : []
97
+ ];
98
+ if (columns < 21) return heading.join("\n");
99
+ return [...MARK.map((row) => ` ${brand(row)}`), "", ...heading].join("\n");
100
+ }
101
+
102
+ // ../dev-console/src/index.mjs
69
103
  var ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
70
104
  var ansi = {
71
105
  reset: "\x1B[0m",
@@ -116,7 +150,7 @@ function statusLine(theme, label, value, tone = "text") {
116
150
  }
117
151
  function printReady({ title, subtitle, lines, theme }) {
118
152
  console.log("");
119
- console.log(` ${theme.brand(title)} ${theme.muted(subtitle)}`);
153
+ console.log(renderBrandHeader({ title, subtitle, color: theme.enabled }));
120
154
  for (const [label, value, tone = "text"] of lines) {
121
155
  if (value === null || value === void 0) continue;
122
156
  console.log(statusLine(theme, label, value, tone));
@@ -28,7 +28,12 @@ export {
28
28
  type InstructionContext,
29
29
  } from './roles/contract.ts';
30
30
  export { parseHandoff, type HandoffDeclaration } from './roles/handoff.ts';
31
- export { selectTaskSkill, type TaskSkillContext } from './roles/skills.ts';
31
+ export {
32
+ ALWAYS_ELIGIBLE_SKILLS,
33
+ ROLE_SKILLS,
34
+ selectTaskSkill,
35
+ type TaskSkillContext,
36
+ } from './roles/skills.ts';
32
37
  export { DEFAULT_AGENT_ROLES } from './roles/defaults.ts';
33
38
  export type { AgentRoleDefinition } from './roles/defaults.ts';
34
39
  export {
@@ -85,7 +85,11 @@ Leave client files to the frontend engineer and tools/business-agent definitions
85
85
  allowedPaths: ['spec/**', 'translations/**'],
86
86
  gates: ['spec-schema'],
87
87
  handoff: ['backend-engineer', 'ux-designer'],
88
- instruction: `You own specification decisions and locale terminology, never implementation. Use only the Task skill selected under Session. Consult reference/packages/contracts/schemas/module-spec.schema.json and reference/example-module/spec/module.yaml when writing the spec.
88
+ instruction: `You own specification decisions and locale terminology, never implementation. Use only the Task skill selected under Session. Consult reference/platform-capabilities.md, reference/packages/contracts/schemas/module-spec.schema.json and reference/example-module/spec/module.yaml when writing the spec.
89
+
90
+ Write schemaVersion 2: entities with typed fields and states, screens, actions, widgets, settings, agentTools, plus outOfScope and decisions. Fill decisions for every choice, including the platform defaults you proposed. The capability card is closed: anything it lists as missing goes to outOfScope with the business decision, never into a scenario. v1 specs stay valid.
91
+
92
+ When a decision is missing, end the reply with exactly one fenced block tagged questions holding {"questions":[{"id":"Q-1","question":"...","options":["..."],"recommended":"...","allowFreeText":true}]} and nothing after it. The operator answers in a form and the replies arrive next turn as a Decisions section.
89
93
 
90
94
  For an edit, compare against base/modules/<dir>/spec/module.yaml and make the smallest delta covering the brief. Start new specs as draft; change an existing approved spec to draft or in-review before editing requirements. Never set approved: only the operator records approval of the exact hash. Later edits invalidate it.
91
95
 
@@ -3,13 +3,28 @@ export interface TaskSkillContext {
3
3
  readonly sessionKind: 'new-module' | 'edit-module';
4
4
  readonly blueprint: string;
5
5
  readonly task: string;
6
+ /* The text an explicit $skill may come from. Defaults to the request text.
7
+ A turn whose request also carries words a specialist wrote narrows it to
8
+ the operator's own, so a question can never choose the skill that answers
9
+ it; empty means this turn has no operator text to scan. The keyword
10
+ defaults keep reading `task`, which is the whole request either way. */
11
+ readonly explicitSkillSource?: string;
6
12
  readonly available: readonly string[];
13
+ /* Whether the operator has approved the exact current specification of the
14
+ module this turn works on. Absent counts as not approved, so a new module
15
+ starts with the interview instead of the implementation skill. */
16
+ readonly specApproved?: boolean;
7
17
  }
8
18
 
9
19
  /* A single deterministic selection. Discovery is not injected into the prompt.
10
20
  Only skills this specialist can use are eligible for an explicit $skill. */
11
- const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
12
- 'business-manager': ['module-new', 'module-update'],
21
+ export const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
22
+ 'business-manager': [
23
+ 'spec-interview',
24
+ 'module-new',
25
+ 'module-update',
26
+ 'translations-i18n',
27
+ ],
13
28
  'backend-engineer': [
14
29
  'module-new',
15
30
  'module-update',
@@ -18,17 +33,19 @@ const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
18
33
  'bug-hunt',
19
34
  'test-hardening',
20
35
  'auth-security-review',
36
+ 'cli-extension',
21
37
  'perf-audit',
22
38
  ],
23
39
  'frontend-engineer': [
24
40
  'ux-design',
25
41
  'module-update',
42
+ 'translations-i18n',
26
43
  'variables',
27
44
  'workflow-development',
28
45
  'bug-hunt',
29
46
  'test-hardening',
30
47
  ],
31
- 'ux-designer': ['ux-design'],
48
+ 'ux-designer': ['ux-design', 'translations-i18n'],
32
49
  'agentic-engineer': [
33
50
  'agent-tool-design',
34
51
  'business-agent-design',
@@ -38,13 +55,23 @@ const ROLE_SKILLS: Readonly<Record<string, readonly string[]>> = {
38
55
  ],
39
56
  };
40
57
 
58
+ /* Review is read-only and is available to custom registered specialists too, so
59
+ it is eligible for every role without appearing in any role's list. */
60
+ export const ALWAYS_ELIGIBLE_SKILLS: readonly string[] = ['auto-review'];
61
+
41
62
  export function selectTaskSkill(context: TaskSkillContext): string | null {
42
- // Review is read-only and is available to custom registered specialists too.
43
- const eligible = [...(ROLE_SKILLS[context.role] ?? []), 'auto-review'];
63
+ const eligible = [
64
+ ...(ROLE_SKILLS[context.role] ?? []),
65
+ ...ALWAYS_ELIGIBLE_SKILLS,
66
+ ];
44
67
  const available = new Set(context.available);
45
68
  const installed = (skill: string) =>
46
69
  eligible.includes(skill) && available.has(skill);
47
- const explicit = [...context.task.matchAll(/\$([a-z][a-z0-9-]*)\b/g)]
70
+ const explicit = [
71
+ ...(context.explicitSkillSource ?? context.task).matchAll(
72
+ /\$([a-z][a-z0-9-]*)\b/g,
73
+ ),
74
+ ]
48
75
  .map((match) => match[1]!)
49
76
  .find(installed);
50
77
  if (explicit) return explicit;
@@ -56,6 +83,15 @@ export function selectTaskSkill(context: TaskSkillContext): string | null {
56
83
  ? 'bug-hunt'
57
84
  : null;
58
85
  if (focused && installed(focused)) return focused;
86
+ /* A new module starts as an interview, not as an implementation: until the
87
+ operator approves the exact spec there is nothing to implement from. */
88
+ if (
89
+ context.role === 'business-manager' &&
90
+ context.sessionKind === 'new-module' &&
91
+ context.specApproved !== true &&
92
+ installed('spec-interview')
93
+ )
94
+ return 'spec-interview';
59
95
  const moduleSkill =
60
96
  context.sessionKind === 'new-module' ? 'module-new' : 'module-update';
61
97
  const defaultSkill =
@@ -229,6 +229,10 @@ export function spawnLineStream(options: SpawnJsonOptions): ProcessLineStream {
229
229
  });
230
230
  });
231
231
 
232
+ /* A spawn failure rejects before the caller has drained the lines and
233
+ awaited this promise; without a handler of its own that rejection is
234
+ unhandled and takes the process down. */
235
+ finished.catch(() => undefined);
232
236
  return {
233
237
  lines: createInterface({ input: child.stdout, crlfDelay: Infinity }),
234
238
  finished,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sandbox",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
@@ -55,9 +55,9 @@
55
55
  "@octanejs/seo": "0.0.38",
56
56
  "@octanejs/vite-plugin": "0.1.51",
57
57
  "octane": "0.1.51",
58
- "segment-state": "0.2.0",
58
+ "segment-state": "0.2.1",
59
59
  "vite": "8.2.2",
60
- "@flowdular/sdk": "0.2.3"
60
+ "@flowdular/sdk": "0.3.0"
61
61
  },
62
62
  "imports": {
63
63
  "#coding-agent": "./internal/coding-agent/src/index.ts"
package/src/App.tsrx CHANGED
@@ -31,9 +31,11 @@ import {
31
31
  stopTurn,
32
32
  streamEject,
33
33
  streamTurn,
34
+ submitAnswers,
34
35
  uploadAttachment,
35
36
  type EjectPlanView,
36
37
  type EjectTargetId,
38
+ type SubmittedAnswer,
37
39
  type TurnHandlers,
38
40
  } from './client/api.ts';
39
41
  import { ChatPane } from './client/ChatPane.tsrx';
@@ -54,11 +56,13 @@ import {
54
56
  type GitHubSettingsInput,
55
57
  } from './client/GitHubSettingsModal.tsrx';
56
58
  import {
59
+ answersErrorFor,
57
60
  createSandboxClientState,
58
61
  mergeEntries,
59
62
  mergeSteps,
60
63
  signalReady,
61
64
  sessionIdFromUrl,
65
+ type AnswersRefusal,
62
66
  type PreviewSelection,
63
67
  } from './client/state.ts';
64
68
  import {
@@ -121,6 +125,11 @@ export function App(props?: RenderRouteProps) @{
121
125
  const [ejecting, setEjecting] = useValue(sandbox.state.ejecting);
122
126
  const [ejectBuild, setEjectBuild] = useValue(sandbox.state.ejectBuild);
123
127
  const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
128
+ /* A refused answer belongs beside the form that was refused, not in the
129
+ session-wide banner and not beside another session's form. */
130
+ const [answersRefusal, setAnswersRefusal] = useState<AnswersRefusal | null>(
131
+ null,
132
+ );
124
133
  const [menuOpen, setMenuOpen] = useValue(sandbox.state.menuOpen);
125
134
  const [githubSettingsOpen, setGitHubSettingsOpen] = useValue(
126
135
  sandbox.state.githubSettingsOpen,
@@ -380,6 +389,35 @@ export function App(props?: RenderRouteProps) @{
380
389
  );
381
390
  };
382
391
 
392
+ /* Answering the open questions is a turn the server composes: it prepends the
393
+ decisions to the optional note and resumes the specialist that asked. */
394
+ const answerQuestions = (
395
+ answers: readonly SubmittedAnswer[],
396
+ note: string,
397
+ ) => {
398
+ const session = currentSession();
399
+ if (!session || sandbox.store.get(sandbox.state.running)) return;
400
+ const sequence = session.pendingQuestions?.sequence ?? -1;
401
+ setAnswersRefusal(null);
402
+ detach();
403
+ setRunning(true);
404
+ setError('');
405
+ const handlers = handlersFor(session.id);
406
+ turn.controller = submitAnswers(session.id, answers, note, {
407
+ ...handlers,
408
+ onFailed: (failure) => {
409
+ if (sandbox.store.get(sandbox.state.activeSessionId) !== session.id) {
410
+ return;
411
+ }
412
+ setAnswersRefusal({
413
+ session: session.id,
414
+ sequence,
415
+ message: failure,
416
+ });
417
+ },
418
+ });
419
+ };
420
+
383
421
  const continueWith = (handoff: HandoffPlan) => {
384
422
  const session = currentSession();
385
423
  if (!session || running) return;
@@ -863,6 +901,7 @@ export function App(props?: RenderRouteProps) @{
863
901
  <ChatPane
864
902
  entries={entries}
865
903
  delivered={Boolean(view.session.ejectedAt)}
904
+ archived={Boolean(view.session.archivedAt)}
866
905
  roles={state.roles}
867
906
  drivers={state.drivers}
868
907
  modules={view.session.modules}
@@ -875,6 +914,12 @@ export function App(props?: RenderRouteProps) @{
875
914
  busy={busy}
876
915
  selection={selection}
877
916
  autoContinue={view.session.autoContinue}
917
+ pendingQuestions={view.session.pendingQuestions ?? null}
918
+ answersError={answersErrorFor(
919
+ answersRefusal,
920
+ view.session.id,
921
+ view.session.pendingQuestions ?? null,
922
+ )}
878
923
  pendingBrief={startingPoint(view.session, entries)}
879
924
  onStart={() => send(startingPoint(view.session, entries))}
880
925
  onContinue={continueWith}
@@ -893,6 +938,7 @@ export function App(props?: RenderRouteProps) @{
893
938
  onDriver={setDriver}
894
939
  onMessage={setDraft}
895
940
  onSend={(fresh) => send(undefined, fresh)}
941
+ onAnswers={answerQuestions}
896
942
  onStop={stop}
897
943
  onClearSelection={() => setSelection(null)}
898
944
  />
@@ -16,7 +16,14 @@ import {
16
16
  type DriverSummary,
17
17
  type ModuleSpecReview,
18
18
  type RoleSummary,
19
+ type SubmittedAnswer,
19
20
  } from './api.ts';
21
+ import {
22
+ readQuestions,
23
+ type PendingQuestion,
24
+ type PendingQuestions,
25
+ } from '../server/questions.ts';
26
+ import { PendingQuestionsCard } from './PendingQuestionsCard.tsrx';
20
27
  import { ToolEvent } from './ToolEvent.tsrx';
21
28
  import { transcriptRows, type TranscriptRow } from './transcript.ts';
22
29
  import { TurnActivity } from './TurnActivity.tsrx';
@@ -34,6 +41,9 @@ import { roleLabel, handoffLabel } from './session-labels.ts';
34
41
  export interface ChatPaneProps {
35
42
  readonly entries: readonly ChatEntry[];
36
43
  readonly delivered: boolean;
44
+ /* Archived and delivered both close the session to work, so a pending
45
+ decision form is read-only under either. */
46
+ readonly archived: boolean;
37
47
  readonly roles: readonly RoleSummary[];
38
48
  readonly drivers: readonly DriverSummary[];
39
49
  readonly modules: readonly SessionModule[];
@@ -48,6 +58,9 @@ export interface ChatPaneProps {
48
58
  readonly busy?: boolean;
49
59
  readonly selection: PreviewSelection | null;
50
60
  readonly autoContinue: boolean;
61
+ /* The decisions the specialist that asked is still waiting for. */
62
+ readonly pendingQuestions: PendingQuestions | null;
63
+ readonly answersError: string;
51
64
  /* Set when the session has never run a turn, so the brief can still start it. */
52
65
  readonly pendingBrief: string;
53
66
  readonly onStart: () => void;
@@ -61,6 +74,7 @@ export interface ChatPaneProps {
61
74
  readonly onDriver: (driver: string) => void;
62
75
  readonly onMessage: (message: string) => void;
63
76
  readonly onSend: (freshContext?: boolean) => void;
77
+ readonly onAnswers: (answers: readonly SubmittedAnswer[], message: string) => void;
64
78
  readonly onStop: () => void;
65
79
  readonly onClearSelection: () => void;
66
80
  }
@@ -103,10 +117,11 @@ function isError(entry: ChatEntry): boolean {
103
117
 
104
118
  const HANDOFF_LINE = /^handoff\s*:/i;
105
119
 
106
- /* The handoff line is protocol, not conversation: the card below the message
107
- already says who continues, so the transcript shows the words only. */
108
- function spoken(entry: ChatEntry): string {
109
- const lines = (entry.text ?? '').split('\n');
120
+ /* The handoff line and the questions block are protocol, not conversation: the
121
+ cards below the message already say who continues and what is being asked,
122
+ so the transcript shows the words only. */
123
+ function spoken(entry: ChatEntry, text: string): string {
124
+ const lines = text.split('\n');
110
125
  while (lines.length > 0 &&
111
126
  lines[lines.length - 1]!.trim() === '') lines.pop();
112
127
  const last = lines[lines.length - 1]?.replace(/[`*_]/g, '').trim() ?? '';
@@ -120,6 +135,11 @@ interface StreamBlock {
120
135
  readonly key: number;
121
136
  readonly entry: ChatEntry | null;
122
137
  readonly events: readonly TranscriptRow[] | null;
138
+ /* The message as words, with the protocol lines taken out. */
139
+ readonly speech: string;
140
+ /* The decisions this message asked for, read once so the transcript can
141
+ list them instead of showing the operator raw JSON. */
142
+ readonly questions: readonly PendingQuestion[];
123
143
  }
124
144
 
125
145
  /* One stream in the order it happened: a message, then the tool activity that
@@ -150,13 +170,26 @@ function streamBlocks(entries: readonly ChatEntry[]): readonly StreamBlock[] {
150
170
  }
151
171
  blocks.push({ key: entry.sequence, entry, events: null });
152
172
  }
153
- return blocks.map(
154
- (block) => ({
173
+ return blocks.map((block) => {
174
+ const reading =
175
+ block.entry?.kind === 'agent'
176
+ ? readQuestions(block.entry.text ?? '')
177
+ : null;
178
+ return {
155
179
  key: block.key,
156
180
  entry: block.entry,
157
181
  events: block.events,
158
- }),
159
- );
182
+ speech: block.entry
183
+ ? spoken(
184
+ block.entry,
185
+ reading?.kind === 'valid'
186
+ ? reading.remainder
187
+ : block.entry.text ?? '',
188
+ )
189
+ : '',
190
+ questions: reading?.kind === 'valid' ? reading.questions : [],
191
+ };
192
+ });
160
193
  }
161
194
 
162
195
  function blockKey(block: StreamBlock): number {
@@ -484,8 +517,19 @@ export function ChatPane(props: ChatPaneProps) @{
484
517
  <p class="chat__text">{block.entry!.text}</p>
485
518
  </details>
486
519
  }
487
- @if (block.entry!.sequence === open && !props.running) {
488
- @if (block.entry!.handoff!.kind === 'approval') {
520
+ @if (block.entry!.sequence === open) {
521
+ @if (block.entry!.handoff!.kind === 'question') {
522
+ <PendingQuestionsCard
523
+ pending={props.pendingQuestions}
524
+ loading={props.running}
525
+ denied={props.delivered || props.archived}
526
+ busy={props.busy === true}
527
+ error={props.answersError}
528
+ onSubmit={props.onAnswers}
529
+ />
530
+ } @else if (
531
+ block.entry!.handoff!.kind === 'approval' && !props.running
532
+ ) {
489
533
  <ApprovalHandoff
490
534
  handoff={block.entry!.handoff!}
491
535
  reviews={props.specs}
@@ -494,7 +538,9 @@ export function ChatPane(props: ChatPaneProps) @{
494
538
  onRequestChanges={props.onRequestChanges}
495
539
  onEdit={props.onEditSpec}
496
540
  />
497
- } @else if (block.entry!.handoff!.kind === 'continue') {
541
+ } @else if (
542
+ block.entry!.handoff!.kind === 'continue' && !props.running
543
+ ) {
498
544
  <div class="ui-form__actions">
499
545
  <Button
500
546
  size="sm"
@@ -530,7 +576,22 @@ export function ChatPane(props: ChatPaneProps) @{
530
576
  </span>
531
577
  {moduleLabel(block.entry!)}
532
578
  </header>
533
- <p class="chat__text">{spoken(block.entry!)}</p>
579
+ <p class="chat__text">{block.speech}</p>
580
+ @if (block.questions.length > 0) {
581
+ <ul>
582
+ @for (const question of block.questions; key question.id) {
583
+ <li>
584
+ <span class="ui-mono">{question.id}</span>
585
+ {' ' + question.question}
586
+ @if (question.options.length > 0) {
587
+ <span class="ui-help">{question.options.join(
588
+ ' · ',
589
+ )}</span>
590
+ }
591
+ </li>
592
+ }
593
+ </ul>
594
+ }
534
595
  {restoreRow(block.key)}
535
596
  </article>
536
597
  }