@flowdular/sdk 0.1.0 → 0.2.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.
Files changed (174) hide show
  1. package/.ai/skills/test-hardening/SKILL.md +1 -1
  2. package/README.md +69 -12
  3. package/assets/flowdular-banner.png +0 -0
  4. package/modules/agents/package.json +1 -1
  5. package/modules/auth/package.json +1 -1
  6. package/modules/auth/src/client/AuthenticationCore.tsrx +3 -1
  7. package/modules/auth/src/server/endpoints.ts +3 -1
  8. package/modules/auth/src/server/runtime.ts +4 -0
  9. package/modules/automations/package.json +1 -1
  10. package/modules/automations-workflows-integration/package.json +1 -1
  11. package/modules/profile/package.json +1 -1
  12. package/modules/sandbox/package.json +1 -1
  13. package/modules/system/package.json +1 -1
  14. package/modules/users/package.json +1 -1
  15. package/modules/workflows/package.json +1 -1
  16. package/package.json +5 -13
  17. package/packages/ai-provider/package.json +1 -1
  18. package/packages/cli-protocol/package.json +1 -1
  19. package/packages/client/package.json +1 -1
  20. package/packages/client/src/routing.ts +39 -2
  21. package/packages/client/src/shell/BrandMark.tsrx +5 -1
  22. package/packages/client/src/shell/navigation.ts +3 -1
  23. package/packages/client/src/state.ts +6 -2
  24. package/packages/contracts/package.json +1 -1
  25. package/packages/contracts/schemas/project.schema.json +16 -42
  26. package/packages/database/package.json +1 -1
  27. package/packages/database-pglite/package.json +1 -1
  28. package/packages/database-testing/package.json +1 -1
  29. package/packages/dev-console/package.json +1 -1
  30. package/packages/harness/package.json +1 -1
  31. package/packages/kernel/package.json +1 -1
  32. package/packages/server/package.json +1 -1
  33. package/packages/server/src/application-routes.ts +80 -8
  34. package/packages/server/src/index.ts +4 -1
  35. package/packages/server/src/web-html.ts +9 -5
  36. package/packages/server/src/web.ts +59 -7
  37. package/packages/ui/package.json +1 -1
  38. package/packages/coding-agent/package.json +0 -44
  39. package/packages/coding-agent/src/drivers/byok.ts +0 -322
  40. package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
  41. package/packages/coding-agent/src/drivers/codex.ts +0 -281
  42. package/packages/coding-agent/src/index.ts +0 -56
  43. package/packages/coding-agent/src/registry.ts +0 -119
  44. package/packages/coding-agent/src/roles/contract.ts +0 -60
  45. package/packages/coding-agent/src/roles/defaults.ts +0 -134
  46. package/packages/coding-agent/src/roles/handoff.ts +0 -41
  47. package/packages/coding-agent/src/roles/registry.ts +0 -156
  48. package/packages/coding-agent/src/roles/skills.ts +0 -72
  49. package/packages/coding-agent/src/roles/sync.ts +0 -88
  50. package/packages/coding-agent/src/types.ts +0 -102
  51. package/packages/coding-agent/src/workspace.ts +0 -275
  52. package/packages/sandbox/README.md +0 -691
  53. package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
  54. package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
  55. package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
  56. package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
  57. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  58. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  59. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  60. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  61. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  62. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  63. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  64. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  65. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  66. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  67. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  68. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  69. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  70. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  71. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  72. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  73. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  74. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  75. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  76. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  77. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  78. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  79. package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  80. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  81. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  82. package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  83. package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
  84. package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
  85. package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
  86. package/packages/sandbox/dist/client/favicon.svg +0 -1
  87. package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
  88. package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
  89. package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
  90. package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
  91. package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
  92. package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
  93. package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
  94. package/packages/sandbox/dist/server/entry.js +0 -91671
  95. package/packages/sandbox/dist/server/favicon.svg +0 -1
  96. package/packages/sandbox/dist/server/index.html +0 -161
  97. package/packages/sandbox/index.html +0 -159
  98. package/packages/sandbox/octane.config.ts +0 -36
  99. package/packages/sandbox/package.json +0 -83
  100. package/packages/sandbox/public/favicon.svg +0 -1
  101. package/packages/sandbox/public/og.png +0 -0
  102. package/packages/sandbox/src/App.tsrx +0 -924
  103. package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
  104. package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
  105. package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
  106. package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
  107. package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
  108. package/packages/sandbox/src/client/GateResults.tsrx +0 -60
  109. package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
  110. package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
  111. package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
  112. package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
  113. package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
  114. package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
  115. package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
  116. package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
  117. package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
  118. package/packages/sandbox/src/client/api.ts +0 -797
  119. package/packages/sandbox/src/client/attachments.ts +0 -73
  120. package/packages/sandbox/src/client/dashboard.ts +0 -46
  121. package/packages/sandbox/src/client/github-settings.ts +0 -48
  122. package/packages/sandbox/src/client/highlight.ts +0 -105
  123. package/packages/sandbox/src/client/i18n.ts +0 -32
  124. package/packages/sandbox/src/client/locales/en.json +0 -466
  125. package/packages/sandbox/src/client/locales/pl.json +0 -466
  126. package/packages/sandbox/src/client/session-labels.ts +0 -77
  127. package/packages/sandbox/src/client/state.ts +0 -108
  128. package/packages/sandbox/src/index.ts +0 -10
  129. package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
  130. package/packages/sandbox/src/preview/load-module.ts +0 -16
  131. package/packages/sandbox/src/server/attachments.ts +0 -308
  132. package/packages/sandbox/src/server/auto-review.ts +0 -181
  133. package/packages/sandbox/src/server/checkpoints.ts +0 -90
  134. package/packages/sandbox/src/server/config.ts +0 -460
  135. package/packages/sandbox/src/server/dashboard.ts +0 -245
  136. package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
  137. package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
  138. package/packages/sandbox/src/server/delivery/index.ts +0 -83
  139. package/packages/sandbox/src/server/delivery/local.ts +0 -151
  140. package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
  141. package/packages/sandbox/src/server/delivery/plan.ts +0 -218
  142. package/packages/sandbox/src/server/delivery/policies.ts +0 -239
  143. package/packages/sandbox/src/server/delivery/record.ts +0 -41
  144. package/packages/sandbox/src/server/delivery/steps.ts +0 -464
  145. package/packages/sandbox/src/server/delivery/types.ts +0 -158
  146. package/packages/sandbox/src/server/dependencies.ts +0 -88
  147. package/packages/sandbox/src/server/diff.ts +0 -230
  148. package/packages/sandbox/src/server/gates.ts +0 -388
  149. package/packages/sandbox/src/server/index.ts +0 -139
  150. package/packages/sandbox/src/server/path-guard.ts +0 -282
  151. package/packages/sandbox/src/server/planning.ts +0 -749
  152. package/packages/sandbox/src/server/platform-client.ts +0 -189
  153. package/packages/sandbox/src/server/preview-database-host.ts +0 -340
  154. package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
  155. package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
  156. package/packages/sandbox/src/server/preview-database.ts +0 -109
  157. package/packages/sandbox/src/server/preview-ipc.ts +0 -15
  158. package/packages/sandbox/src/server/preview-modules.ts +0 -83
  159. package/packages/sandbox/src/server/preview-revision.ts +0 -44
  160. package/packages/sandbox/src/server/preview-runtime.ts +0 -467
  161. package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
  162. package/packages/sandbox/src/server/preview-worker.ts +0 -168
  163. package/packages/sandbox/src/server/reference.ts +0 -251
  164. package/packages/sandbox/src/server/routes.ts +0 -2258
  165. package/packages/sandbox/src/server/runtime.ts +0 -188
  166. package/packages/sandbox/src/server/session-owner.ts +0 -23
  167. package/packages/sandbox/src/server/sessions.ts +0 -849
  168. package/packages/sandbox/src/server/spec.ts +0 -471
  169. package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
  170. package/packages/sandbox/src/server/turns.ts +0 -993
  171. package/packages/sandbox/src/server/workspace-install.ts +0 -266
  172. package/packages/sandbox/src/server/workspace-root.ts +0 -73
  173. package/packages/sandbox/src/styles.css +0 -1573
  174. package/packages/sandbox/vite.config.ts +0 -133
@@ -1,749 +0,0 @@
1
- import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { tmpdir } from 'node:os';
4
- import type {
5
- AgentRoleDefinition,
6
- CodingAgentRegistry,
7
- CodingAgentEvent,
8
- HandoffDeclaration,
9
- } from '@flowdular/sdk/coding-agent';
10
- import type { GateResult } from './gates.ts';
11
- import { SandboxSetupError } from './workspace-root.ts';
12
- import {
13
- moduleSuffixOf,
14
- type HandoffPlan,
15
- type SandboxSession,
16
- type SessionModule,
17
- type SessionPaths,
18
- } from './sessions.ts';
19
-
20
- export interface WorkspaceModule {
21
- readonly id: string;
22
- readonly directory: string;
23
- readonly name: string;
24
- }
25
-
26
- export interface WorkPlan {
27
- readonly kind: 'new-module' | 'edit-module';
28
- /* The primary module: modules[0]. */
29
- readonly moduleId: string;
30
- readonly title: string;
31
- readonly sourceModule: string | null;
32
- /* Every module the work touches, primary first. A known id is a change to
33
- that module; an unknown id is a new module. */
34
- readonly modules: readonly SessionModule[];
35
- readonly firstRole: string;
36
- readonly rationale: string;
37
- readonly classifiedBy: 'agent' | 'rules';
38
- }
39
-
40
- /* Who owns spec/module.yaml, for a new module and for a change alike. */
41
- export const SPEC_OWNER_ROLE = 'business-manager';
42
- /* Who implements when nothing better is routed. */
43
- const DEFAULT_IMPLEMENTER_ROLE = 'backend-engineer';
44
-
45
- const PLAN_TIMEOUT_MS = 3 * 60 * 1000;
46
- const MODULE_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
47
- /* Bounded output for the fix prompt; the transcript keeps the whole output. */
48
- const GATE_PROMPT_OUTPUT = 4_000;
49
-
50
- export async function listWorkspaceModules(
51
- workspaceRoot: string,
52
- ): Promise<readonly WorkspaceModule[]> {
53
- let entries: readonly string[] = [];
54
- try {
55
- entries = await readdir(join(workspaceRoot, 'modules'));
56
- } catch {
57
- return [];
58
- }
59
- const modules: WorkspaceModule[] = [];
60
- for (const entry of entries) {
61
- try {
62
- const manifest = JSON.parse(
63
- await readFile(
64
- join(workspaceRoot, 'modules', entry, 'module.json'),
65
- 'utf8',
66
- ),
67
- ) as { id?: string };
68
- if (manifest.id) {
69
- modules.push({ id: manifest.id, directory: entry, name: entry });
70
- }
71
- } catch {
72
- continue;
73
- }
74
- }
75
- return modules;
76
- }
77
-
78
- function slugTitle(brief: string): string {
79
- const words = brief
80
- .replace(/[^a-zA-Z0-9 ]/g, ' ')
81
- .split(/\s+/)
82
- .filter(Boolean)
83
- .slice(0, 4);
84
- return words.length > 0 ? words.join(' ') : 'Module session';
85
- }
86
-
87
- const FILLER = new Set([
88
- 'module',
89
- 'should',
90
- 'would',
91
- 'could',
92
- 'their',
93
- 'there',
94
- 'these',
95
- 'those',
96
- 'about',
97
- 'with',
98
- 'that',
99
- 'this',
100
- 'from',
101
- 'into',
102
- 'when',
103
- 'what',
104
- 'where',
105
- 'which',
106
- 'users',
107
- 'user',
108
- 'want',
109
- 'need',
110
- 'needs',
111
- 'build',
112
- 'create',
113
- 'make',
114
- 'allow',
115
- 'lets',
116
- 'let',
117
- ]);
118
-
119
- function slugModuleId(brief: string): string {
120
- const word = brief
121
- .toLowerCase()
122
- .replace(/[^a-z0-9 ]/g, ' ')
123
- .split(/\s+/)
124
- .find((candidate) => candidate.length > 3 && !FILLER.has(candidate));
125
- return `${word ?? 'draft'}.core`;
126
- }
127
-
128
- function escapeRegExp(value: string): string {
129
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
- }
131
-
132
- /* Where the brief first names this module, or -1. A module is named by its
133
- whole dotted id (auth.core, never "auth" alone) or by an explicit
134
- "module <directory>" phrase. Bare directory words such as "users" are
135
- ordinary English and never select a module. */
136
- export function mentionIndex(brief: string, module: WorkspaceModule): number {
137
- const text = brief.toLowerCase();
138
- const id = new RegExp(
139
- `(^|[^a-z0-9.-])${escapeRegExp(module.id)}(?![a-z0-9-])(?!\\.[a-z0-9])`,
140
- ).exec(text);
141
- const phrase = new RegExp(
142
- `\\bmodule\\s+${escapeRegExp(module.directory)}(?![a-z0-9-])`,
143
- ).exec(text);
144
- const found = [id?.index ?? -1, phrase?.index ?? -1].filter(
145
- (index) => index >= 0,
146
- );
147
- return found.length > 0 ? Math.min(...found) : -1;
148
- }
149
-
150
- export function mentionsModule(
151
- brief: string,
152
- module: WorkspaceModule,
153
- ): boolean {
154
- return mentionIndex(brief, module) >= 0;
155
- }
156
-
157
- function moduleFor(
158
- id: string,
159
- modules: readonly WorkspaceModule[],
160
- ): SessionModule {
161
- const known = modules.find((module) => module.id === id);
162
- return known
163
- ? { id, directory: known.directory, kind: 'edit' }
164
- : { id, directory: moduleSuffixOf(id), kind: 'new' };
165
- }
166
-
167
- /* A module id the workspace does not have yet. Only the `<domain>.core`
168
- convention the planner and the scaffold use counts, so a file name such as
169
- package.json is never read as a request for a module. */
170
- const INVENTED_ID =
171
- /(^|[^a-z0-9.-])([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.core)(?![a-z0-9-])/g;
172
-
173
- /* Every module the brief names, in the order it names them: a known id is a
174
- change to that module, an invented `<domain>.core` id is a new one. */
175
- function namedModules(
176
- brief: string,
177
- modules: readonly WorkspaceModule[],
178
- ): readonly SessionModule[] {
179
- const found: { readonly at: number; readonly module: SessionModule }[] = [];
180
- for (const module of modules) {
181
- const at = mentionIndex(brief, module);
182
- if (at < 0) continue;
183
- found.push({
184
- at,
185
- module: { id: module.id, directory: module.directory, kind: 'edit' },
186
- });
187
- }
188
- const known = new Set(modules.map((module) => module.id));
189
- const seen = new Set<string>();
190
- for (const match of brief.toLowerCase().matchAll(INVENTED_ID)) {
191
- const id = match[2]!;
192
- if (known.has(id) || seen.has(id)) continue;
193
- seen.add(id);
194
- found.push({ at: match.index, module: moduleFor(id, modules) });
195
- }
196
- return found
197
- .sort((left, right) => left.at - right.at)
198
- .map((entry) => entry.module);
199
- }
200
-
201
- function rationaleFor(modules: readonly SessionModule[]): string {
202
- const existing = modules.filter((module) => module.kind === 'edit');
203
- const created = modules.filter((module) => module.kind === 'new');
204
- const parts = [
205
- ...(existing.length > 0
206
- ? [
207
- `${existing.map((module) => module.id).join(' and ')}, ${
208
- existing.length === 1 ? 'an existing module' : 'existing modules'
209
- }`,
210
- ]
211
- : []),
212
- ...(created.length > 0
213
- ? [
214
- `${created.map((module) => module.id).join(' and ')}, which ${
215
- created.length === 1 ? 'does' : 'do'
216
- } not exist yet`,
217
- ]
218
- : []),
219
- ];
220
- return `The request names ${parts.join(', and ')}.`;
221
- }
222
-
223
- function planFor(
224
- modules: readonly SessionModule[],
225
- brief: string,
226
- firstRole: string,
227
- rationale: string,
228
- classifiedBy: WorkPlan['classifiedBy'],
229
- title = slugTitle(brief),
230
- ): WorkPlan {
231
- const primary = modules[0]!;
232
- return {
233
- kind: primary.kind === 'edit' ? 'edit-module' : 'new-module',
234
- moduleId: primary.id,
235
- title,
236
- sourceModule: primary.kind === 'edit' ? primary.directory : null,
237
- modules,
238
- firstRole,
239
- rationale,
240
- classifiedBy,
241
- };
242
- }
243
-
244
- /* Rules first: every existing module named in the request is a change to that
245
- module, and an invented id is a new one. Only what the rules cannot decide is
246
- left to the planner agent. The specification owner always takes the first
247
- turn, because a change is described before it is implemented. */
248
- export function classifyByRules(
249
- brief: string,
250
- modules: readonly WorkspaceModule[],
251
- ): WorkPlan {
252
- const named = namedModules(brief, modules);
253
- if (named.length > 0) {
254
- return planFor(named, brief, SPEC_OWNER_ROLE, rationaleFor(named), 'rules');
255
- }
256
- return planFor(
257
- [moduleFor(slugModuleId(brief), modules)],
258
- brief,
259
- SPEC_OWNER_ROLE,
260
- 'No existing module matches the request.',
261
- 'rules',
262
- );
263
- }
264
-
265
- /* The planner's answer is trusted for what it may know better than the rules
266
- (the title, the first role, a new module's id) and never for what the
267
- workspace already knows: an id that exists is a change to that module. */
268
- export function parsePlan(
269
- output: string,
270
- modules: readonly WorkspaceModule[],
271
- roles: readonly AgentRoleDefinition[],
272
- fallback: WorkPlan,
273
- ): WorkPlan {
274
- const start = output.indexOf('{');
275
- const end = output.lastIndexOf('}');
276
- if (start < 0 || end <= start) return fallback;
277
- let value: Record<string, unknown>;
278
- try {
279
- value = JSON.parse(output.slice(start, end + 1)) as Record<string, unknown>;
280
- } catch {
281
- return fallback;
282
- }
283
- const listed = Array.isArray(value.modules)
284
- ? (value.modules as unknown[]).filter(
285
- (entry): entry is string =>
286
- typeof entry === 'string' && MODULE_ID.test(entry),
287
- )
288
- : [];
289
- const primary =
290
- typeof value.moduleId === 'string' && MODULE_ID.test(value.moduleId)
291
- ? value.moduleId
292
- : (listed[0] ?? fallback.moduleId);
293
- const ids = new Set([primary, ...listed]);
294
- const named = [...ids].map((id) => moduleFor(id, modules));
295
- /* A module this workspace already has and the brief named belongs to the
296
- work whatever the planner listed. */
297
- for (const module of fallback.modules) {
298
- if (module.kind !== 'edit') continue;
299
- if (named.some((entry) => entry.id === module.id)) continue;
300
- named.push(module);
301
- }
302
- const role = roles.find((candidate) => candidate.id === value.firstRole);
303
- return planFor(
304
- named,
305
- '',
306
- role?.id ?? fallback.firstRole,
307
- typeof value.rationale === 'string'
308
- ? value.rationale.trim().slice(0, 240)
309
- : fallback.rationale,
310
- 'agent',
311
- typeof value.title === 'string' && value.title.trim().length > 1
312
- ? value.title.trim().slice(0, 120)
313
- : fallback.title,
314
- );
315
- }
316
-
317
- export interface PlanRequest {
318
- readonly onEvent?: (event: CodingAgentEvent) => void;
319
- readonly brief: string;
320
- readonly driver: string;
321
- readonly registry: CodingAgentRegistry;
322
- readonly roles: readonly AgentRoleDefinition[];
323
- readonly modules: readonly WorkspaceModule[];
324
- readonly signal?: AbortSignal;
325
- }
326
-
327
- /* The planner is a bounded classification turn: it reads nothing, writes
328
- nothing, and answers with one JSON object naming the target modules and the
329
- specialist who starts. Its failure is never fatal, because the rules already
330
- produced a usable plan. */
331
- export async function planWork(request: PlanRequest): Promise<WorkPlan> {
332
- const fallback = classifyByRules(request.brief, request.modules);
333
- let driver;
334
- try {
335
- driver = await request.registry.resolve(request.driver);
336
- } catch {
337
- return fallback;
338
- }
339
-
340
- const catalogue = request.modules
341
- .map((module) => `${module.id} (modules/${module.directory})`)
342
- .join(', ');
343
- const roleList = request.roles
344
- .map((role) => `${role.id}: ${role.purpose}`)
345
- .join('\n');
346
- const instruction = `You are the sandbox planner. You classify one request and answer with a single JSON object. You never create, read, or change files, and you never run commands.
347
-
348
- Answer with exactly this shape and nothing else:
349
- {"kind":"new-module"|"edit-module","moduleId":"lowercase.dotted.id","modules":["lowercase.dotted.id"],"title":"three to six words","firstRole":"one role id","rationale":"one sentence"}
350
-
351
- Rules:
352
- - modules lists every module the request touches, the primary one first; moduleId repeats the primary one.
353
- - A request that spans several modules names all of them, for example {"moduleId":"parties.core","modules":["parties.core","catalog.core"]} for a field added in one module and shown on another module's screen.
354
- - An id from the existing modules list means a change to that module. Any other id means a new module: invent it as domain.core, naming the capability, not the technology.
355
- - kind describes the primary module.
356
- - firstRole is the specialist who should take the first turn.
357
-
358
- Existing modules: ${catalogue || 'none'}
359
-
360
- Roles:
361
- ${roleList}`;
362
-
363
- const workspacePath = await mkdtemp(join(tmpdir(), 'flowdular-plan-'));
364
- let output = '';
365
- try {
366
- for await (const event of driver.run({
367
- workspacePath,
368
- role: 'planner',
369
- systemInstruction: instruction,
370
- prompt: `Request:\n${request.brief}`,
371
- timeoutMs: PLAN_TIMEOUT_MS,
372
- signal: request.signal,
373
- })) {
374
- request.onEvent?.(event);
375
- if (event.type === 'assistant.message') output += `\n${event.text}`;
376
- }
377
- } catch {
378
- return fallback;
379
- } finally {
380
- await rm(workspacePath, { recursive: true, force: true }).catch(
381
- () => undefined,
382
- );
383
- }
384
- return parsePlan(output, request.modules, request.roles, fallback);
385
- }
386
-
387
- export interface RoutingContext {
388
- readonly session: SandboxSession;
389
- readonly paths: SessionPaths;
390
- readonly roles: readonly AgentRoleDefinition[];
391
- readonly message: string;
392
- readonly hasSpec: boolean;
393
- readonly hasManifest: boolean;
394
- readonly hasServer: boolean;
395
- readonly hasClient: boolean;
396
- /* The gate on the module this turn works in: false while the operator has
397
- not approved its specification, null when it has none at all. */
398
- readonly specApproved: boolean | null;
399
- /* The handoff that ended the previous turn, when there was one. */
400
- readonly lastHandoff?: HandoffPlan | null;
401
- }
402
-
403
- const UI_WORDS =
404
- /\b(screen|view|layout|design|ux|widget|dashboard|form|button|copy|wording|empty state)\b/i;
405
- const AGENT_WORDS = /\b(agent|tool|skill|automation|workflow|prompt)\b/i;
406
- const SPEC_WORDS =
407
- /\b(spec|specification|scenario|requirement|acceptance|invariant)\b/i;
408
-
409
- /* Routing is deterministic and explainable: the state of the module decides
410
- who works next, and the words of the request can only move the choice
411
- between specialists that are already valid for that state. */
412
- export function routeRole(context: RoutingContext): {
413
- readonly role: string;
414
- readonly reason: string;
415
- } {
416
- const has = (id: string) =>
417
- context.roles.some((role) => role.id === id) ? id : context.session.role;
418
-
419
- /* An operator message that follows a question is the answer to it, so it
420
- goes back to the specialist who asked instead of to the default owner. */
421
- if (
422
- context.lastHandoff?.kind === 'question' &&
423
- context.roles.some((role) => role.id === context.lastHandoff!.role)
424
- ) {
425
- return {
426
- role: context.lastHandoff.role,
427
- reason: `${context.lastHandoff.roleName} asked the question this message answers.`,
428
- };
429
- }
430
- if (
431
- SPEC_WORDS.test(context.message) ||
432
- !context.hasSpec ||
433
- context.specApproved === false
434
- ) {
435
- return {
436
- role: has(SPEC_OWNER_ROLE),
437
- reason: !context.hasSpec
438
- ? 'The module has no approved specification yet.'
439
- : context.specApproved === false
440
- ? 'The specification of this module is not approved yet, so nobody implements before it is.'
441
- : 'The request is about the specification.',
442
- };
443
- }
444
- if (AGENT_WORDS.test(context.message)) {
445
- return {
446
- role: has('agentic-engineer'),
447
- reason: 'The request is about the agent surface of the module.',
448
- };
449
- }
450
- if (UI_WORDS.test(context.message)) {
451
- return {
452
- role: has(context.hasClient ? 'frontend-engineer' : 'ux-designer'),
453
- reason: context.hasClient
454
- ? 'The request changes an existing screen.'
455
- : 'The module has no screen yet.',
456
- };
457
- }
458
- if (!context.hasManifest || !context.hasServer) {
459
- return {
460
- role: has('backend-engineer'),
461
- reason: 'The module still needs its server surface.',
462
- };
463
- }
464
- if (!context.hasClient) {
465
- return {
466
- role: has('frontend-engineer'),
467
- reason: 'The server exists and the module has no client yet.',
468
- };
469
- }
470
- return {
471
- role: has('backend-engineer'),
472
- reason: 'Default owner for a change with no clearer signal.',
473
- };
474
- }
475
-
476
- export interface HandoffContext {
477
- readonly reviewing?: boolean;
478
- readonly routing: RoutingContext;
479
- /* The role that just finished its turn. */
480
- readonly role: string;
481
- /* The draft module directory that turn worked in. */
482
- readonly module: string;
483
- readonly declared: HandoffDeclaration | null;
484
- readonly gates: readonly GateResult[];
485
- readonly failed: boolean;
486
- readonly changed: boolean;
487
- /* null when the module has no specification file at all. */
488
- readonly specApproved: boolean | null;
489
- readonly brief: string;
490
- }
491
-
492
- function roleName(roles: readonly AgentRoleDefinition[], id: string): string {
493
- return roles.find((role) => role.id === id)?.name ?? id;
494
- }
495
-
496
- function continuePrompt(name: string, reason: string, brief: string): string {
497
- return [
498
- `Continue this work as ${name}.`,
499
- reason ? `The previous specialist reported: ${reason}` : '',
500
- brief ? `The original request was: ${brief}` : '',
501
- 'Do your part of it now, then end with your handoff line.',
502
- ]
503
- .filter(Boolean)
504
- .join('\n\n');
505
- }
506
-
507
- function gateLabel(gate: GateResult): string {
508
- return gate.module ? `${gate.id} (modules/${gate.module})` : gate.id;
509
- }
510
-
511
- /* The declared handoff is honoured only when it names a role the finishing
512
- role may hand to. Anything else (an unknown role, a role outside the list,
513
- the role itself) falls back to the state routing with a note that says so. */
514
- function validateDeclared(context: HandoffContext): {
515
- readonly role: string | null;
516
- readonly note: string;
517
- } {
518
- const declared = context.declared?.role;
519
- if (!declared) return { role: null, note: '' };
520
- const roles = context.routing.roles;
521
- const current = roles.find((role) => role.id === context.role);
522
- if (declared === context.role) {
523
- return {
524
- role: null,
525
- note: `${roleName(roles, context.role)} named itself in the handoff line, so the state routing decided.`,
526
- };
527
- }
528
- if (!roles.some((role) => role.id === declared)) {
529
- return {
530
- role: null,
531
- note: `The handoff line named ${declared}, which is not a registered role, so the state routing decided.`,
532
- };
533
- }
534
- if (current && !current.handoff.includes(declared)) {
535
- return {
536
- role: null,
537
- note: `${current.name} may not hand off to ${roleName(roles, declared)}, so the state routing decided.`,
538
- };
539
- }
540
- return { role: declared, note: '' };
541
- }
542
-
543
- /* Who works next, decided in one place. The specialist's own handoff line is
544
- trusted when it names a role it may hand to, the deterministic routing
545
- answers when it does not, and an unapproved specification always stops for
546
- the operator. */
547
- export function planHandoff(context: HandoffContext): HandoffPlan {
548
- const roles = context.routing.roles;
549
- const plan = (
550
- kind: HandoffPlan['kind'],
551
- role: string,
552
- reason: string,
553
- prompt = '',
554
- module = context.module,
555
- ): HandoffPlan => ({
556
- kind,
557
- role,
558
- roleName: roleName(roles, role),
559
- reason,
560
- prompt,
561
- module,
562
- });
563
-
564
- if (context.failed) {
565
- return plan(
566
- 'blocked',
567
- context.role,
568
- 'The coding agent stopped with an error, so nothing continues on its own.',
569
- );
570
- }
571
-
572
- /* A failed gate belongs to the module it ran in, so the fix turn works
573
- there even when the finished turn worked somewhere else. */
574
- const failedGate = context.gates.find((gate) => gate.status !== 'passed');
575
- if (failedGate) {
576
- const repairRole = context.reviewing
577
- ? (validateDeclared(context).role ?? context.role)
578
- : context.role;
579
- return plan(
580
- 'continue',
581
- repairRole,
582
- `The ${gateLabel(failedGate)} gate failed, so the responsible specialist fixes it before delivery.`,
583
- [
584
- `The ${gateLabel(failedGate)} gate failed after your change. Fix exactly what it reports, change nothing else, and end with your handoff line.`,
585
- `Gate command: ${failedGate.command}`,
586
- `Gate output (first ${GATE_PROMPT_OUTPUT} characters; the transcript holds the rest):`,
587
- failedGate.output.slice(0, GATE_PROMPT_OUTPUT),
588
- ].join('\n\n'),
589
- failedGate.module ?? context.module,
590
- );
591
- }
592
-
593
- const validated = validateDeclared(context);
594
- const declared = validated.role;
595
- const routed = routeRole(context.routing).role;
596
- const next = declared ?? (routed === context.role ? null : routed);
597
- const withNote = (reason: string) =>
598
- validated.note ? `${reason} ${validated.note}` : reason;
599
- const finished = context.declared !== null && context.declared.role === null;
600
-
601
- /* A turn that finished without touching a file did not finish the work: it
602
- needs an answer, not a review, whatever the specification's status. */
603
- if (finished && !context.changed) {
604
- return plan(
605
- 'question',
606
- context.role,
607
- context.declared!.reason ||
608
- 'The specialist needs an answer before it can continue.',
609
- );
610
- }
611
-
612
- if (context.specApproved === false) {
613
- const implementer = next ?? DEFAULT_IMPLEMENTER_ROLE;
614
- return plan(
615
- 'approval',
616
- implementer,
617
- withNote('The specification is ready for your review.'),
618
- continuePrompt(
619
- roleName(roles, implementer),
620
- context.declared?.reason ?? '',
621
- context.brief,
622
- ),
623
- );
624
- }
625
-
626
- /* The specification is approved, so a business manager that wrote it hands
627
- the change on to the implementer instead of reporting the request done. */
628
- if (
629
- finished &&
630
- context.routing.session.kind === 'edit-module' &&
631
- context.role === SPEC_OWNER_ROLE
632
- ) {
633
- const implementer =
634
- routed !== context.role && roles.some((role) => role.id === routed)
635
- ? routed
636
- : DEFAULT_IMPLEMENTER_ROLE;
637
- return plan(
638
- 'continue',
639
- implementer,
640
- `The specification is updated; ${roleName(roles, implementer)} implements the change.`,
641
- continuePrompt(
642
- roleName(roles, implementer),
643
- context.declared?.reason ?? '',
644
- context.brief,
645
- ),
646
- );
647
- }
648
-
649
- if (finished) {
650
- return plan(
651
- 'review',
652
- context.role,
653
- context.declared!.reason ||
654
- 'The specialist reports the request is fully satisfied.',
655
- );
656
- }
657
-
658
- if (!next) {
659
- return plan(
660
- context.changed ? 'review' : 'question',
661
- context.role,
662
- withNote(
663
- context.changed
664
- ? 'Nothing else is routed automatically. Review the change and eject it when you are happy.'
665
- : 'The turn changed nothing. Answer what the specialist asked, or say what should happen next.',
666
- ),
667
- );
668
- }
669
-
670
- return plan(
671
- 'continue',
672
- next,
673
- withNote(
674
- (declared && context.declared?.reason) ||
675
- `${roleName(roles, next)} owns what remains after this turn.`,
676
- ),
677
- continuePrompt(
678
- roleName(roles, next),
679
- declared ? (context.declared?.reason ?? '') : '',
680
- context.brief,
681
- ),
682
- );
683
- }
684
-
685
- export interface SpecGateContext {
686
- readonly roles: readonly AgentRoleDefinition[];
687
- /* The draft module directory the refused turn was for. */
688
- readonly module: string;
689
- readonly refusedRole: string;
690
- /* True when the draft specification already carries a change to review. */
691
- readonly changed: boolean;
692
- readonly brief: string;
693
- }
694
-
695
- /* The move after a turn an implementer may not take. A specification change on
696
- the table is the operator's to review; without one the specification owner
697
- writes it first, which is what keeps a change spec-driven. */
698
- export function planSpecGateHandoff(context: SpecGateContext): HandoffPlan {
699
- const named = (id: string) => roleName(context.roles, id);
700
- if (context.changed) {
701
- return {
702
- kind: 'approval',
703
- role: context.refusedRole,
704
- roleName: named(context.refusedRole),
705
- reason: 'The specification change is ready for your review.',
706
- prompt: continuePrompt(named(context.refusedRole), '', context.brief),
707
- module: context.module,
708
- };
709
- }
710
- if (!context.roles.some((role) => role.id === SPEC_OWNER_ROLE)) {
711
- return {
712
- kind: 'blocked',
713
- role: context.refusedRole,
714
- roleName: named(context.refusedRole),
715
- reason:
716
- 'The specification is not approved and the business-manager role is not configured, so implementation remains blocked.',
717
- prompt: '',
718
- module: context.module,
719
- };
720
- }
721
- return {
722
- kind: 'continue',
723
- role: SPEC_OWNER_ROLE,
724
- roleName: named(SPEC_OWNER_ROLE),
725
- reason: `The specification does not describe this change yet, so ${named(
726
- SPEC_OWNER_ROLE,
727
- )} writes the delta before anyone implements it.`,
728
- prompt: [
729
- 'Update this module specification so it describes the requested change, and change nothing else.',
730
- context.brief ? `The request was: ${context.brief}` : '',
731
- 'Bump specVersion, add or change the acceptance scenarios the change needs, and state the permissions, invariants and data ownership it introduces. A small change deserves a small delta. Leave status as it is: the operator approves it.',
732
- 'End with your handoff line.',
733
- ]
734
- .filter(Boolean)
735
- .join('\n\n'),
736
- module: context.module,
737
- };
738
- }
739
-
740
- export function assertBrief(value: string): string {
741
- const brief = value.trim();
742
- if (brief.length < 8 || brief.length > 20_000) {
743
- throw new SandboxSetupError(
744
- 'INVALID_BRIEF',
745
- 'Describe the work in at least 8 characters.',
746
- );
747
- }
748
- return brief;
749
- }