@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.
- package/.ai/skills/test-hardening/SKILL.md +1 -1
- package/README.md +69 -12
- package/assets/flowdular-banner.png +0 -0
- package/modules/agents/package.json +1 -1
- package/modules/auth/package.json +1 -1
- package/modules/auth/src/client/AuthenticationCore.tsrx +3 -1
- package/modules/auth/src/server/endpoints.ts +3 -1
- package/modules/auth/src/server/runtime.ts +4 -0
- package/modules/automations/package.json +1 -1
- package/modules/automations-workflows-integration/package.json +1 -1
- package/modules/profile/package.json +1 -1
- package/modules/sandbox/package.json +1 -1
- package/modules/system/package.json +1 -1
- package/modules/users/package.json +1 -1
- package/modules/workflows/package.json +1 -1
- package/package.json +5 -13
- package/packages/ai-provider/package.json +1 -1
- package/packages/cli-protocol/package.json +1 -1
- package/packages/client/package.json +1 -1
- package/packages/client/src/routing.ts +39 -2
- package/packages/client/src/shell/BrandMark.tsrx +5 -1
- package/packages/client/src/shell/navigation.ts +3 -1
- package/packages/client/src/state.ts +6 -2
- package/packages/contracts/package.json +1 -1
- package/packages/contracts/schemas/project.schema.json +16 -42
- package/packages/database/package.json +1 -1
- package/packages/database-pglite/package.json +1 -1
- package/packages/database-testing/package.json +1 -1
- package/packages/dev-console/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/kernel/package.json +1 -1
- package/packages/server/package.json +1 -1
- package/packages/server/src/application-routes.ts +80 -8
- package/packages/server/src/index.ts +4 -1
- package/packages/server/src/web-html.ts +9 -5
- package/packages/server/src/web.ts +59 -7
- package/packages/ui/package.json +1 -1
- package/packages/coding-agent/package.json +0 -44
- package/packages/coding-agent/src/drivers/byok.ts +0 -322
- package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
- package/packages/coding-agent/src/drivers/codex.ts +0 -281
- package/packages/coding-agent/src/index.ts +0 -56
- package/packages/coding-agent/src/registry.ts +0 -119
- package/packages/coding-agent/src/roles/contract.ts +0 -60
- package/packages/coding-agent/src/roles/defaults.ts +0 -134
- package/packages/coding-agent/src/roles/handoff.ts +0 -41
- package/packages/coding-agent/src/roles/registry.ts +0 -156
- package/packages/coding-agent/src/roles/skills.ts +0 -72
- package/packages/coding-agent/src/roles/sync.ts +0 -88
- package/packages/coding-agent/src/types.ts +0 -102
- package/packages/coding-agent/src/workspace.ts +0 -275
- package/packages/sandbox/README.md +0 -691
- package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
- package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
- package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
- package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
- package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
- package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
- package/packages/sandbox/dist/client/favicon.svg +0 -1
- package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
- package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
- package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
- package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
- package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
- package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
- package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
- package/packages/sandbox/dist/server/entry.js +0 -91671
- package/packages/sandbox/dist/server/favicon.svg +0 -1
- package/packages/sandbox/dist/server/index.html +0 -161
- package/packages/sandbox/index.html +0 -159
- package/packages/sandbox/octane.config.ts +0 -36
- package/packages/sandbox/package.json +0 -83
- package/packages/sandbox/public/favicon.svg +0 -1
- package/packages/sandbox/public/og.png +0 -0
- package/packages/sandbox/src/App.tsrx +0 -924
- package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
- package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
- package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
- package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
- package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
- package/packages/sandbox/src/client/GateResults.tsrx +0 -60
- package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
- package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
- package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
- package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
- package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
- package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
- package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
- package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
- package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
- package/packages/sandbox/src/client/api.ts +0 -797
- package/packages/sandbox/src/client/attachments.ts +0 -73
- package/packages/sandbox/src/client/dashboard.ts +0 -46
- package/packages/sandbox/src/client/github-settings.ts +0 -48
- package/packages/sandbox/src/client/highlight.ts +0 -105
- package/packages/sandbox/src/client/i18n.ts +0 -32
- package/packages/sandbox/src/client/locales/en.json +0 -466
- package/packages/sandbox/src/client/locales/pl.json +0 -466
- package/packages/sandbox/src/client/session-labels.ts +0 -77
- package/packages/sandbox/src/client/state.ts +0 -108
- package/packages/sandbox/src/index.ts +0 -10
- package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
- package/packages/sandbox/src/preview/load-module.ts +0 -16
- package/packages/sandbox/src/server/attachments.ts +0 -308
- package/packages/sandbox/src/server/auto-review.ts +0 -181
- package/packages/sandbox/src/server/checkpoints.ts +0 -90
- package/packages/sandbox/src/server/config.ts +0 -460
- package/packages/sandbox/src/server/dashboard.ts +0 -245
- package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
- package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
- package/packages/sandbox/src/server/delivery/index.ts +0 -83
- package/packages/sandbox/src/server/delivery/local.ts +0 -151
- package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
- package/packages/sandbox/src/server/delivery/plan.ts +0 -218
- package/packages/sandbox/src/server/delivery/policies.ts +0 -239
- package/packages/sandbox/src/server/delivery/record.ts +0 -41
- package/packages/sandbox/src/server/delivery/steps.ts +0 -464
- package/packages/sandbox/src/server/delivery/types.ts +0 -158
- package/packages/sandbox/src/server/dependencies.ts +0 -88
- package/packages/sandbox/src/server/diff.ts +0 -230
- package/packages/sandbox/src/server/gates.ts +0 -388
- package/packages/sandbox/src/server/index.ts +0 -139
- package/packages/sandbox/src/server/path-guard.ts +0 -282
- package/packages/sandbox/src/server/planning.ts +0 -749
- package/packages/sandbox/src/server/platform-client.ts +0 -189
- package/packages/sandbox/src/server/preview-database-host.ts +0 -340
- package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
- package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
- package/packages/sandbox/src/server/preview-database.ts +0 -109
- package/packages/sandbox/src/server/preview-ipc.ts +0 -15
- package/packages/sandbox/src/server/preview-modules.ts +0 -83
- package/packages/sandbox/src/server/preview-revision.ts +0 -44
- package/packages/sandbox/src/server/preview-runtime.ts +0 -467
- package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
- package/packages/sandbox/src/server/preview-worker.ts +0 -168
- package/packages/sandbox/src/server/reference.ts +0 -251
- package/packages/sandbox/src/server/routes.ts +0 -2258
- package/packages/sandbox/src/server/runtime.ts +0 -188
- package/packages/sandbox/src/server/session-owner.ts +0 -23
- package/packages/sandbox/src/server/sessions.ts +0 -849
- package/packages/sandbox/src/server/spec.ts +0 -471
- package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
- package/packages/sandbox/src/server/turns.ts +0 -993
- package/packages/sandbox/src/server/workspace-install.ts +0 -266
- package/packages/sandbox/src/server/workspace-root.ts +0 -73
- package/packages/sandbox/src/styles.css +0 -1573
- 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
|
-
}
|