@flowdular/sdk 0.1.0 → 0.2.1

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 (175) 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 +2 -2
  28. package/packages/database-pglite/src/driver.ts +21 -3
  29. package/packages/database-testing/package.json +1 -1
  30. package/packages/dev-console/package.json +1 -1
  31. package/packages/harness/package.json +1 -1
  32. package/packages/kernel/package.json +1 -1
  33. package/packages/server/package.json +1 -1
  34. package/packages/server/src/application-routes.ts +80 -8
  35. package/packages/server/src/index.ts +4 -1
  36. package/packages/server/src/web-html.ts +9 -5
  37. package/packages/server/src/web.ts +59 -7
  38. package/packages/ui/package.json +1 -1
  39. package/packages/coding-agent/package.json +0 -44
  40. package/packages/coding-agent/src/drivers/byok.ts +0 -322
  41. package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
  42. package/packages/coding-agent/src/drivers/codex.ts +0 -281
  43. package/packages/coding-agent/src/index.ts +0 -56
  44. package/packages/coding-agent/src/registry.ts +0 -119
  45. package/packages/coding-agent/src/roles/contract.ts +0 -60
  46. package/packages/coding-agent/src/roles/defaults.ts +0 -134
  47. package/packages/coding-agent/src/roles/handoff.ts +0 -41
  48. package/packages/coding-agent/src/roles/registry.ts +0 -156
  49. package/packages/coding-agent/src/roles/skills.ts +0 -72
  50. package/packages/coding-agent/src/roles/sync.ts +0 -88
  51. package/packages/coding-agent/src/types.ts +0 -102
  52. package/packages/coding-agent/src/workspace.ts +0 -275
  53. package/packages/sandbox/README.md +0 -691
  54. package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
  55. package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
  56. package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
  57. package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
  58. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  59. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  60. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  61. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  62. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  63. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  64. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  65. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  66. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  67. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  68. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  69. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  70. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  71. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  72. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  73. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  74. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  75. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  76. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  77. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  78. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  79. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  80. package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  81. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  82. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  83. package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  84. package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
  85. package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
  86. package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
  87. package/packages/sandbox/dist/client/favicon.svg +0 -1
  88. package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
  89. package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
  90. package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
  91. package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
  92. package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
  93. package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
  94. package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
  95. package/packages/sandbox/dist/server/entry.js +0 -91671
  96. package/packages/sandbox/dist/server/favicon.svg +0 -1
  97. package/packages/sandbox/dist/server/index.html +0 -161
  98. package/packages/sandbox/index.html +0 -159
  99. package/packages/sandbox/octane.config.ts +0 -36
  100. package/packages/sandbox/package.json +0 -83
  101. package/packages/sandbox/public/favicon.svg +0 -1
  102. package/packages/sandbox/public/og.png +0 -0
  103. package/packages/sandbox/src/App.tsrx +0 -924
  104. package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
  105. package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
  106. package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
  107. package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
  108. package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
  109. package/packages/sandbox/src/client/GateResults.tsrx +0 -60
  110. package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
  111. package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
  112. package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
  113. package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
  114. package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
  115. package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
  116. package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
  117. package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
  118. package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
  119. package/packages/sandbox/src/client/api.ts +0 -797
  120. package/packages/sandbox/src/client/attachments.ts +0 -73
  121. package/packages/sandbox/src/client/dashboard.ts +0 -46
  122. package/packages/sandbox/src/client/github-settings.ts +0 -48
  123. package/packages/sandbox/src/client/highlight.ts +0 -105
  124. package/packages/sandbox/src/client/i18n.ts +0 -32
  125. package/packages/sandbox/src/client/locales/en.json +0 -466
  126. package/packages/sandbox/src/client/locales/pl.json +0 -466
  127. package/packages/sandbox/src/client/session-labels.ts +0 -77
  128. package/packages/sandbox/src/client/state.ts +0 -108
  129. package/packages/sandbox/src/index.ts +0 -10
  130. package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
  131. package/packages/sandbox/src/preview/load-module.ts +0 -16
  132. package/packages/sandbox/src/server/attachments.ts +0 -308
  133. package/packages/sandbox/src/server/auto-review.ts +0 -181
  134. package/packages/sandbox/src/server/checkpoints.ts +0 -90
  135. package/packages/sandbox/src/server/config.ts +0 -460
  136. package/packages/sandbox/src/server/dashboard.ts +0 -245
  137. package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
  138. package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
  139. package/packages/sandbox/src/server/delivery/index.ts +0 -83
  140. package/packages/sandbox/src/server/delivery/local.ts +0 -151
  141. package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
  142. package/packages/sandbox/src/server/delivery/plan.ts +0 -218
  143. package/packages/sandbox/src/server/delivery/policies.ts +0 -239
  144. package/packages/sandbox/src/server/delivery/record.ts +0 -41
  145. package/packages/sandbox/src/server/delivery/steps.ts +0 -464
  146. package/packages/sandbox/src/server/delivery/types.ts +0 -158
  147. package/packages/sandbox/src/server/dependencies.ts +0 -88
  148. package/packages/sandbox/src/server/diff.ts +0 -230
  149. package/packages/sandbox/src/server/gates.ts +0 -388
  150. package/packages/sandbox/src/server/index.ts +0 -139
  151. package/packages/sandbox/src/server/path-guard.ts +0 -282
  152. package/packages/sandbox/src/server/planning.ts +0 -749
  153. package/packages/sandbox/src/server/platform-client.ts +0 -189
  154. package/packages/sandbox/src/server/preview-database-host.ts +0 -340
  155. package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
  156. package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
  157. package/packages/sandbox/src/server/preview-database.ts +0 -109
  158. package/packages/sandbox/src/server/preview-ipc.ts +0 -15
  159. package/packages/sandbox/src/server/preview-modules.ts +0 -83
  160. package/packages/sandbox/src/server/preview-revision.ts +0 -44
  161. package/packages/sandbox/src/server/preview-runtime.ts +0 -467
  162. package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
  163. package/packages/sandbox/src/server/preview-worker.ts +0 -168
  164. package/packages/sandbox/src/server/reference.ts +0 -251
  165. package/packages/sandbox/src/server/routes.ts +0 -2258
  166. package/packages/sandbox/src/server/runtime.ts +0 -188
  167. package/packages/sandbox/src/server/session-owner.ts +0 -23
  168. package/packages/sandbox/src/server/sessions.ts +0 -849
  169. package/packages/sandbox/src/server/spec.ts +0 -471
  170. package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
  171. package/packages/sandbox/src/server/turns.ts +0 -993
  172. package/packages/sandbox/src/server/workspace-install.ts +0 -266
  173. package/packages/sandbox/src/server/workspace-root.ts +0 -73
  174. package/packages/sandbox/src/styles.css +0 -1573
  175. package/packages/sandbox/vite.config.ts +0 -133
@@ -1,1473 +0,0 @@
1
- import { sandboxDirectory } from '../config.ts';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join, relative } from 'node:path';
5
- import { redactSecrets } from '@flowdular/sdk/ai-provider';
6
- import type { GateResult } from '../gates.ts';
7
- import {
8
- basePathOf,
9
- findSessionModule,
10
- modulePathOf,
11
- readChat,
12
- sessionPaths,
13
- type SandboxSession,
14
- } from '../sessions.ts';
15
- import { readModuleSpecReview, type ModuleSpecReview } from '../spec.ts';
16
- import { SandboxSetupError } from '../workspace-root.ts';
17
- import {
18
- DEFAULT_DELIVERY_CONFIGURATION,
19
- type DeliveryConfiguration,
20
- } from './configuration.ts';
21
- import {
22
- GATES,
23
- assertEjectCapability,
24
- countChangedFiles,
25
- planSessionModules,
26
- } from './plan.ts';
27
- import {
28
- loadPathOwnership,
29
- loadTaskBudgets,
30
- matchesPath,
31
- ownerOf,
32
- type PathOwnership,
33
- type TaskBudgets,
34
- } from './policies.ts';
35
- import {
36
- createStepRecorder,
37
- enableModule,
38
- exists,
39
- installWorktree,
40
- runDeliveryGates,
41
- stageModules,
42
- step,
43
- verifyPlatform,
44
- type CommandResult,
45
- type StepRecorder,
46
- type StepResult,
47
- } from './steps.ts';
48
- import type {
49
- DeliveryAvailability,
50
- DeliveryBudget,
51
- DeliveryContext,
52
- DeliveryGuardrails,
53
- DeliveryModulePlan,
54
- DeliveryProvider,
55
- DeliveryTarget,
56
- GitDeliveryMode,
57
- GitDeliveryPlan,
58
- } from './types.ts';
59
-
60
- function worktreesDirectory(root: string): string {
61
- return relative(root, join(sandboxDirectory(root), 'worktrees'));
62
- }
63
- /* Written by module enable and pnpm install; they travel in the module's
64
- commit (.ai/blueprints/new-module/allowed-paths.yaml, cliOwned). */
65
- const COMPOSITION_PATHS = [
66
- 'flowdular.json',
67
- 'platform/package.json',
68
- 'platform/src/generated/**',
69
- ] as const;
70
- const LOCKFILE_PATH = 'pnpm-lock.yaml';
71
- const FALLBACK_BUDGET: DeliveryBudget = {
72
- maxChangedFiles: 18,
73
- maxNewDependencies: 0,
74
- };
75
- const NO_COMMITS =
76
- 'the repository has no commits yet; make the first commit before delivering as a pull request';
77
- const OFFENDING_LIMIT = 20;
78
- const GITHUB_REMOTE =
79
- /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com(?::\d+)?\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/;
80
-
81
- const COMMAND_ENVIRONMENT_KEYS = new Set([
82
- 'HOME',
83
- 'LANG',
84
- 'LC_ALL',
85
- 'LC_CTYPE',
86
- 'LOGNAME',
87
- 'PATH',
88
- 'SHELL',
89
- 'SSH_AUTH_SOCK',
90
- 'SSL_CERT_DIR',
91
- 'SSL_CERT_FILE',
92
- 'TEMP',
93
- 'TMP',
94
- 'TMPDIR',
95
- 'USER',
96
- 'XDG_CACHE_HOME',
97
- 'XDG_CONFIG_HOME',
98
- 'XDG_DATA_HOME',
99
- 'XDG_STATE_HOME',
100
- ]);
101
-
102
- type Run = (
103
- command: string,
104
- args: readonly string[],
105
- cwd: string,
106
- ) => Promise<CommandResult>;
107
-
108
- interface ChangeSet {
109
- readonly added: readonly string[];
110
- readonly modified: readonly string[];
111
- readonly removed: readonly string[];
112
- }
113
-
114
- interface GitDestination {
115
- readonly repository: string | null;
116
- readonly mode: GitDeliveryMode;
117
- readonly pushTarget: string;
118
- readonly forkOwner: string | null;
119
- readonly forkRequired: boolean;
120
- readonly head: string;
121
- readonly compareUrl: string | null;
122
- }
123
-
124
- function sameStrings(
125
- left: readonly string[],
126
- right: readonly string[],
127
- ): boolean {
128
- return (
129
- left.length === right.length &&
130
- left.every((value, index) => value === right[index])
131
- );
132
- }
133
-
134
- /* Apply never trusts an earlier preview blindly. In particular, a spec can be
135
- edited after the operator opens the plan, which invalidates its approved
136
- hash. Rebuild the module plan at the last boundary before delivery and fail
137
- rather than commit a different set of files from the one reviewed. */
138
- function assertPlanStillCurrent(
139
- planned: readonly DeliveryModulePlan[],
140
- current: readonly DeliveryModulePlan[],
141
- ): void {
142
- const same =
143
- planned.length === current.length &&
144
- planned.every((module, index) => {
145
- const refreshed = current[index];
146
- return (
147
- refreshed !== undefined &&
148
- module.id === refreshed.id &&
149
- module.directory === refreshed.directory &&
150
- module.kind === refreshed.kind &&
151
- module.targetPath === refreshed.targetPath &&
152
- sameStrings(module.files, refreshed.files) &&
153
- sameStrings(module.additions, refreshed.additions) &&
154
- sameStrings(module.overwrites, refreshed.overwrites) &&
155
- sameStrings(module.removes, refreshed.removes) &&
156
- sameStrings(module.newPackages, refreshed.newPackages) &&
157
- module.enable === refreshed.enable
158
- );
159
- });
160
- if (!same) {
161
- throw new SandboxSetupError(
162
- 'EJECT_PLAN_STALE',
163
- 'The session changed after this delivery was reviewed. Open a new delivery plan and approve the current specification before trying again.',
164
- );
165
- }
166
- }
167
-
168
- /* One delivery per session at a time: two would fight over the same worktree
169
- path and branch. */
170
- const inFlight = new Set<string>();
171
-
172
- /* Delivery commands need only the operator's executable path, local Git
173
- identity, SSH agent, locale, temporary directory, and certificate paths.
174
- An allowlist prevents unrelated database URLs and cloud credentials from
175
- reaching a package script or an external provider command. */
176
- function commandEnvironment(): NodeJS.ProcessEnv {
177
- const env: NodeJS.ProcessEnv = {};
178
- for (const [key, value] of Object.entries(process.env)) {
179
- if (value === undefined || !COMMAND_ENVIRONMENT_KEYS.has(key)) continue;
180
- env[key] = value;
181
- }
182
- Object.assign(env, {
183
- FORCE_COLOR: '0',
184
- NO_COLOR: '1',
185
- GIT_TERMINAL_PROMPT: '0',
186
- GH_PROMPT_DISABLED: '1',
187
- GH_NO_UPDATE_NOTIFIER: '1',
188
- });
189
- return env;
190
- }
191
-
192
- function githubEnvironment(providerToken: string | null): NodeJS.ProcessEnv {
193
- const env = commandEnvironment();
194
- if (providerToken) env.GH_TOKEN = providerToken;
195
- return env;
196
- }
197
-
198
- function gitEnvironment(providerToken: string | null): NodeJS.ProcessEnv {
199
- const env = commandEnvironment();
200
- if (!providerToken) return env;
201
- /* Keep the credential out of command arguments and remote URLs. Git reads
202
- this process-local config only for the delivery child process. */
203
- env.GIT_CONFIG_COUNT = '1';
204
- env.GIT_CONFIG_KEY_0 = 'http.https://github.com/.extraheader';
205
- env.GIT_CONFIG_VALUE_0 = `AUTHORIZATION: basic ${Buffer.from(
206
- `x-access-token:${providerToken}`,
207
- ).toString('base64')}`;
208
- return env;
209
- }
210
-
211
- function safeExternalText(value: string, providerToken: string | null): string {
212
- let safe = redactSecrets(value)
213
- .replace(/\bgithub_pat_[A-Za-z0-9_]{8,}/gi, '[redacted]')
214
- .replace(/\bgh[pousr]_[A-Za-z0-9_]{8,}/gi, '[redacted]')
215
- .replace(/https:\/\/[^\s/@]+:[^\s/@]+@/gi, 'https://[redacted]@');
216
- if (providerToken) {
217
- safe = safe.split(providerToken).join('[redacted]');
218
- const header = Buffer.from(`x-access-token:${providerToken}`).toString(
219
- 'base64',
220
- );
221
- safe = safe.split(header).join('[redacted]');
222
- }
223
- return safe;
224
- }
225
-
226
- export async function gitDeliveryRunner(
227
- context: DeliveryContext,
228
- ): Promise<Run> {
229
- const token = context.gitProviderToken
230
- ? await context.gitProviderToken()
231
- : null;
232
- const normalEnv = commandEnvironment();
233
- const ghEnv = githubEnvironment(token);
234
- const gitEnv = gitEnvironment(token);
235
- return async (command, args, cwd) => {
236
- const result = await context.commands(command, args, cwd, {
237
- env: command === 'git' ? gitEnv : command === 'gh' ? ghEnv : normalEnv,
238
- });
239
- return { ...result, output: safeExternalText(result.output, token) };
240
- };
241
- }
242
-
243
- function unavailable(reason: string): DeliveryAvailability {
244
- return { available: false, reason };
245
- }
246
-
247
- function branchNameFor(
248
- config: DeliveryConfiguration,
249
- session: SandboxSession,
250
- ): string {
251
- return `${config.git.branchPrefix}/${session.moduleSuffix}-${session.id.slice(0, 8)}`;
252
- }
253
-
254
- function budgetFor(
255
- config: DeliveryConfiguration,
256
- budgets: TaskBudgets,
257
- kind: string,
258
- ): DeliveryBudget {
259
- const override = budgets.overrides[kind] ?? {};
260
- return {
261
- maxChangedFiles:
262
- config.maxChangedFiles ??
263
- override.maxChangedFiles ??
264
- budgets.defaults.maxChangedFiles ??
265
- FALLBACK_BUDGET.maxChangedFiles,
266
- maxNewDependencies:
267
- override.maxNewDependencies ??
268
- budgets.defaults.maxNewDependencies ??
269
- FALLBACK_BUDGET.maxNewDependencies,
270
- };
271
- }
272
-
273
- function ownersOf(
274
- ownership: PathOwnership,
275
- paths: readonly string[],
276
- ): readonly string[] {
277
- const owners = new Set<string>();
278
- for (const path of paths) {
279
- const owner = ownerOf(path, ownership);
280
- if (owner) owners.add(owner);
281
- }
282
- return [...owners].sort();
283
- }
284
-
285
- function listSome(items: readonly string[]): string {
286
- const shown = items.slice(0, OFFENDING_LIMIT).join(', ');
287
- return items.length > OFFENDING_LIMIT
288
- ? `${shown} and ${items.length - OFFENDING_LIMIT} more`
289
- : shown;
290
- }
291
-
292
- function evaluateGuardrails(input: {
293
- readonly offending: readonly string[];
294
- readonly changedFiles: number;
295
- readonly newDependencies: readonly string[];
296
- readonly budget: DeliveryBudget;
297
- }): DeliveryGuardrails {
298
- const reasons: string[] = [];
299
- if (input.offending.length > 0) {
300
- reasons.push(
301
- `${input.offending.length} changed file(s) lie outside the allowed paths: ${listSome(input.offending)}.`,
302
- );
303
- }
304
- if (input.changedFiles > input.budget.maxChangedFiles) {
305
- reasons.push(
306
- `${input.changedFiles} changed files exceed the budget of ${input.budget.maxChangedFiles} (sandbox.delivery.maxChangedFiles in flowdular.json, else .ai/policies/task-budgets.yaml).`,
307
- );
308
- }
309
- if (input.newDependencies.length > input.budget.maxNewDependencies) {
310
- reasons.push(
311
- `${input.newDependencies.length} new package(s) (${listSome(input.newDependencies)}) exceed the budget of ${input.budget.maxNewDependencies} from .ai/policies/task-budgets.yaml.`,
312
- );
313
- }
314
- return { ok: reasons.length === 0, reasons };
315
- }
316
-
317
- function githubRepository(remoteUrl: string): string | null {
318
- const match = GITHUB_REMOTE.exec(remoteUrl.trim());
319
- return match ? `${match[1]}/${match[2]}` : null;
320
- }
321
-
322
- function compareUrlFor(
323
- repository: string,
324
- baseBranch: string,
325
- head: string,
326
- ): string {
327
- return `https://github.com/${repository}/compare/${baseBranch}...${head}?expand=1`;
328
- }
329
-
330
- function githubRepositoryName(repository: string): string {
331
- return repository.slice(repository.indexOf('/') + 1);
332
- }
333
-
334
- function githubPushUrl(owner: string, repository: string): string {
335
- return `https://github.com/${owner}/${githubRepositoryName(repository)}.git`;
336
- }
337
-
338
- async function outputValue(
339
- run: Run,
340
- command: string,
341
- args: readonly string[],
342
- cwd: string,
343
- ): Promise<string | null> {
344
- const result = await run(command, args, cwd);
345
- return result.code === 0 && result.output.trim() !== ''
346
- ? result.output.trim()
347
- : null;
348
- }
349
-
350
- async function assertWorktreeDirectoryIgnored(
351
- run: Run,
352
- cwd: string,
353
- ): Promise<void> {
354
- const ignored = await run(
355
- 'git',
356
- [
357
- 'check-ignore',
358
- '--quiet',
359
- '--no-index',
360
- `${worktreesDirectory(cwd)}/.probe`,
361
- ],
362
- cwd,
363
- );
364
- if (ignored.code !== 0) {
365
- throw new SandboxSetupError(
366
- 'GIT_WORKTREE_DIRECTORY_NOT_IGNORED',
367
- `The ${worktreesDirectory(cwd)} directory is not ignored by Git. Add .flowdular/ to .gitignore before pull request delivery so the active checkout stays unchanged.`,
368
- );
369
- }
370
- }
371
-
372
- async function resolveDestination(
373
- run: Run,
374
- config: DeliveryConfiguration,
375
- cwd: string,
376
- remoteUrl: string,
377
- provider: DeliveryProvider,
378
- branch: string,
379
- ): Promise<GitDestination> {
380
- const detected = githubRepository(remoteUrl);
381
- const repository = config.git.repository ?? detected;
382
- if (
383
- config.git.repository !== null &&
384
- detected !== null &&
385
- config.git.repository.toLowerCase() !== detected.toLowerCase()
386
- ) {
387
- throw new SandboxSetupError(
388
- 'GITHUB_REPOSITORY_MISMATCH',
389
- `The ${config.git.remote} remote points to ${detected}, not the configured ${config.git.repository}. Choose the matching remote before delivering.`,
390
- );
391
- }
392
- let mode: GitDeliveryMode = 'direct';
393
- if (config.git.mode === 'fork') mode = 'fork';
394
- if (config.git.mode === 'auto' && repository && provider === 'github') {
395
- const permission = await outputValue(
396
- run,
397
- 'gh',
398
- ['api', `repos/${repository}`, '--jq', '.permissions.push // false'],
399
- cwd,
400
- );
401
- if (permission !== 'true') {
402
- throw new SandboxSetupError(
403
- 'GITHUB_FORK_OPT_IN_REQUIRED',
404
- 'GitHub did not confirm direct push access. Choose Use a fork to create or use a fork, or choose Direct to attempt the repository push explicitly.',
405
- );
406
- }
407
- }
408
- if (mode === 'fork') {
409
- if (provider !== 'github' || repository === null) {
410
- throw new SandboxSetupError(
411
- 'GITHUB_FORK_UNAVAILABLE',
412
- 'Fork delivery needs a GitHub repository and an authenticated GitHub integration.',
413
- );
414
- }
415
- const forkOwner =
416
- config.git.forkOwner ??
417
- (await outputValue(run, 'gh', ['api', 'user', '--jq', '.login'], cwd));
418
- if (!forkOwner) {
419
- throw new SandboxSetupError(
420
- 'GITHUB_FORK_OWNER_MISSING',
421
- 'GitHub did not return the account that should own the fork.',
422
- );
423
- }
424
- const forkRepository = `${forkOwner}/${githubRepositoryName(repository)}`;
425
- const existing = await run(
426
- 'gh',
427
- [
428
- 'repo',
429
- 'view',
430
- forkRepository,
431
- '--json',
432
- 'nameWithOwner,parent',
433
- '--jq',
434
- '[.nameWithOwner, (.parent.nameWithOwner // "")] | @tsv',
435
- ],
436
- cwd,
437
- );
438
- if (existing.code === 0) {
439
- const [, parent = ''] = existing.output.trim().split('\t');
440
- if (parent.toLowerCase() !== repository.toLowerCase()) {
441
- throw new SandboxSetupError(
442
- 'GITHUB_FORK_MISMATCH',
443
- `${forkRepository} exists but is not a fork of ${repository}. Choose another fork owner or use direct delivery.`,
444
- );
445
- }
446
- }
447
- const head = `${forkOwner}:${branch}`;
448
- return {
449
- repository,
450
- mode,
451
- pushTarget: githubPushUrl(forkOwner, repository),
452
- forkOwner,
453
- forkRequired: existing.code !== 0,
454
- head,
455
- compareUrl: compareUrlFor(repository, config.git.baseBranch, head),
456
- };
457
- }
458
- return {
459
- repository,
460
- mode,
461
- pushTarget: config.git.remote,
462
- forkOwner: null,
463
- forkRequired: false,
464
- head: branch,
465
- compareUrl: repository
466
- ? compareUrlFor(repository, config.git.baseBranch, branch)
467
- : null,
468
- };
469
- }
470
-
471
- async function probeProvider(
472
- run: Run,
473
- config: DeliveryConfiguration,
474
- cwd: string,
475
- ): Promise<{ readonly kind: DeliveryProvider; readonly note: string }> {
476
- if (config.git.provider === 'none') {
477
- return {
478
- kind: 'none',
479
- note: 'Pull requests are turned off (sandbox.delivery.git.provider is none); the branch is pushed and the compare link shown.',
480
- };
481
- }
482
- const status = await run('gh', ['auth', 'status'], cwd);
483
- if (status.code === 0) return { kind: 'github', note: '' };
484
- return {
485
- kind: 'none',
486
- note:
487
- status.code === null
488
- ? 'The gh command is not installed. The branch is pushed and a compare link is shown instead of opening a pull request.'
489
- : 'gh is not signed in. The branch is pushed and a compare link is shown instead of opening a pull request.',
490
- };
491
- }
492
-
493
- function porcelainPaths(output: string): readonly string[] {
494
- const paths: string[] = [];
495
- for (const line of output.split('\n')) {
496
- if (line.length < 4) continue;
497
- let path = line.slice(3);
498
- const renamed = path.indexOf(' -> ');
499
- if (renamed >= 0) path = path.slice(renamed + 4);
500
- if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
501
- paths.push(path);
502
- }
503
- return paths;
504
- }
505
-
506
- /* `modules/<dir>/**` becomes the directory pathspec git understands. */
507
- function pathspecOf(pattern: string): string {
508
- return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern;
509
- }
510
-
511
- async function stagedChanges(run: Run, worktree: string): Promise<ChangeSet> {
512
- const diff = await run(
513
- 'git',
514
- ['diff', '--cached', '--name-status', '--no-renames'],
515
- worktree,
516
- );
517
- const added: string[] = [];
518
- const modified: string[] = [];
519
- const removed: string[] = [];
520
- for (const line of diff.output.split('\n')) {
521
- const [status, path] = line.split('\t');
522
- if (!status || !path) continue;
523
- if (status.startsWith('A')) added.push(path);
524
- else if (status.startsWith('D')) removed.push(path);
525
- else modified.push(path);
526
- }
527
- return { added, modified, removed };
528
- }
529
-
530
- async function withTemporaryFile<T>(
531
- content: string,
532
- use: (path: string) => Promise<T>,
533
- ): Promise<T> {
534
- const directory = await mkdtemp(join(tmpdir(), 'flowdular-delivery-'));
535
- try {
536
- const path = join(directory, 'message.md');
537
- await writeFile(path, content, 'utf8');
538
- return await use(path);
539
- } finally {
540
- await rm(directory, { recursive: true, force: true });
541
- }
542
- }
543
-
544
- function urlIn(output: string): string | null {
545
- const matches = output.match(/https?:\/\/\S+/g);
546
- return matches?.at(-1) ?? null;
547
- }
548
-
549
- /* "add a.core and update b.core", in the tense the caller needs. */
550
- function describeChange(
551
- modules: readonly DeliveryModulePlan[],
552
- tense: 'add' | 'adds',
553
- ): string {
554
- const added = modules.filter((module) => module.kind === 'new');
555
- const updated = modules.filter((module) => module.kind === 'edit');
556
- const parts: string[] = [];
557
- if (added.length > 0) {
558
- parts.push(
559
- `${tense === 'adds' ? 'adds' : 'add'} ${added.map((module) => module.id).join(', ')}`,
560
- );
561
- }
562
- if (updated.length > 0) {
563
- parts.push(
564
- `${tense === 'adds' ? 'updates' : 'update'} ${updated.map((module) => module.id).join(', ')}`,
565
- );
566
- }
567
- return parts.join(' and ');
568
- }
569
-
570
- function capitalize(text: string): string {
571
- return text.charAt(0).toUpperCase() + text.slice(1);
572
- }
573
-
574
- function pullRequestTitle(modules: readonly DeliveryModulePlan[]): string {
575
- const complete = capitalize(describeChange(modules, 'add'));
576
- if (complete.length <= 70) return complete;
577
- const primary = modules[0]!.id;
578
- return modules.length === 1
579
- ? `${primary}: sandbox delivery`.slice(0, 70)
580
- : `${primary}: update ${modules.length} modules`.slice(0, 70);
581
- }
582
-
583
- /* Plain sentences from free text: whitespace collapsed, dashes the repository
584
- rules forbid replaced, cut to a sentence count and a length. */
585
- function sentences(text: string, count: number, limit: number): string {
586
- const clean = safeExternalText(text, null)
587
- .replace(
588
- /\b(token|password|secret|api[ _-]?key)\s*[:=]\s*[^\s,;]+/gi,
589
- '$1: [redacted]',
590
- )
591
- .replace(/\s+/g, ' ')
592
- .replace(/\s*\u2014\s*/g, ', ')
593
- .replace(/\u2013/g, '-')
594
- .trim();
595
- if (clean === '') return '';
596
- let picked = clean
597
- .split(/(?<=[.!?])\s+/)
598
- .slice(0, count)
599
- .join(' ');
600
- if (picked.length > limit)
601
- picked = `${picked.slice(0, limit - 1).trimEnd()}.`;
602
- return /[.!?]$/.test(picked) ? picked : `${picked}.`;
603
- }
604
-
605
- function gateSummary(gates: readonly GateResult[]): string {
606
- const modulesByGate = new Map<string, string[]>();
607
- for (const gate of gates) {
608
- const modules = modulesByGate.get(gate.id) ?? [];
609
- if (gate.module) modules.push(gate.module);
610
- modulesByGate.set(gate.id, modules);
611
- }
612
- return [...modulesByGate]
613
- .map(([id, modules]) =>
614
- modules.length > 0 ? `${id} (${modules.join(', ')})` : id,
615
- )
616
- .join(', ');
617
- }
618
-
619
- function commitMessage(
620
- session: SandboxSession,
621
- modules: readonly DeliveryModulePlan[],
622
- gates: readonly GateResult[],
623
- ): string {
624
- return `sandbox: ${describeChange(modules, 'add')}\n\nSession ${session.id}.\nGates passed: ${gateSummary(gates)}.\n`;
625
- }
626
-
627
- async function lastReviewHandoff(
628
- workspaceRoot: string,
629
- session: SandboxSession,
630
- ): Promise<string | null> {
631
- const entries = await readChat(workspaceRoot, session);
632
- for (let index = entries.length - 1; index >= 0; index -= 1) {
633
- const handoff = entries[index]!.handoff;
634
- if (handoff && (handoff.kind === 'review' || handoff.kind === 'approval')) {
635
- return handoff.reason;
636
- }
637
- }
638
- return null;
639
- }
640
-
641
- function pullRequestBody(input: {
642
- readonly session: SandboxSession;
643
- readonly modules: readonly DeliveryModulePlan[];
644
- readonly gates: readonly GateResult[];
645
- readonly changes: ChangeSet;
646
- readonly owners: readonly string[];
647
- readonly requireReviewer: boolean;
648
- readonly handoff: string | null;
649
- readonly specs: readonly ModuleSpecReview[];
650
- }): string {
651
- const testGates = input.gates.filter((gate) => gate.id === 'tests');
652
- const summary = [
653
- `${capitalize(describeChange(input.modules, 'adds'))}.`,
654
- sentences(input.session.brief, 2, 400),
655
- input.handoff ? sentences(input.handoff, 1, 300) : '',
656
- ]
657
- .filter((part) => part !== '')
658
- .join(' ');
659
- const lines = [
660
- summary,
661
- '',
662
- `Session ${input.session.id}.`,
663
- '',
664
- '## Specification',
665
- '',
666
- ...input.specs.flatMap((spec) => {
667
- const before = spec.base?.specVersion ?? 'new';
668
- const after = spec.draft?.specVersion ?? 'unknown';
669
- const changes = spec.changes.slice(0, 20).map((change) => {
670
- const key = change.key ? ` ${change.key}` : '';
671
- return `- ${change.kind}: ${change.field}${key}`;
672
- });
673
- return [
674
- `### ${spec.moduleId}`,
675
- '',
676
- `Version: ${before} to ${after}. Status: ${spec.status ?? 'missing'}.`,
677
- ...(changes.length > 0 ? changes : ['- No field-level spec diff.']),
678
- ...(spec.changes.length > changes.length
679
- ? [`- ${spec.changes.length - changes.length} more spec changes.`]
680
- : []),
681
- '',
682
- ];
683
- }),
684
- '## Gates',
685
- '',
686
- '| Gate | Module | Result |',
687
- '| --- | --- | --- |',
688
- ...input.gates.map(
689
- (gate) => `| ${gate.id} | ${gate.module ?? ''} | ${gate.status} |`,
690
- ),
691
- '',
692
- `Verification: ${testGates.length} module test gate(s) passed. The platform typecheck passed in the delivery worktree.`,
693
- '',
694
- ];
695
- const groups: readonly [string, readonly string[]][] = [
696
- ['Added', input.changes.added],
697
- ['Modified', input.changes.modified],
698
- ['Removed', input.changes.removed],
699
- ];
700
- lines.push('## Files', '');
701
- for (const [label, files] of groups) {
702
- if (files.length === 0) continue;
703
- lines.push(`${label} (${files.length}):`);
704
- lines.push(...files.map((file) => `- ${file}`));
705
- lines.push('');
706
- }
707
- lines.push('## Risks', '');
708
- const migrations = [...input.changes.added, ...input.changes.modified].filter(
709
- (file) => /\/migrations\//.test(file),
710
- );
711
- const removals = input.changes.removed.length;
712
- const riskLines = [
713
- ...(migrations.length > 0
714
- ? [`- ${migrations.length} migration file(s) require deployment review.`]
715
- : []),
716
- ...(removals > 0
717
- ? [`- ${removals} file(s) are removed by this change.`]
718
- : []),
719
- ...(input.requireReviewer && input.owners.length > 1
720
- ? [`- Cross-owner review is required for ${input.owners.join(', ')}.`]
721
- : []),
722
- ];
723
- lines.push(
724
- ...(riskLines.length > 0
725
- ? riskLines
726
- : [
727
- '- No elevated delivery risks were detected by the sandbox guardrails.',
728
- ]),
729
- '',
730
- );
731
- for (const module of input.modules) {
732
- lines.push(
733
- `Post-merge: \`pnpm flowdular auth sync-scopes --module ${module.id} --apply\` against the deployment database.`,
734
- );
735
- }
736
- if (input.requireReviewer && input.owners.length > 1) {
737
- lines.push(
738
- '',
739
- `Cross-owner change (${input.owners.join(', ')}): .ai/policies/path-ownership.yaml asks for a reviewer from each owner.`,
740
- );
741
- }
742
- return `${lines.join('\n')}\n`;
743
- }
744
-
745
- async function specificationEvidence(
746
- workspaceRoot: string,
747
- session: SandboxSession,
748
- ): Promise<readonly ModuleSpecReview[]> {
749
- const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
750
- return Promise.all(
751
- session.modules.map((entry) => {
752
- const module = findSessionModule(session, entry.directory);
753
- return readModuleSpecReview(
754
- module,
755
- modulePathOf(paths, module.directory),
756
- basePathOf(paths, module.directory),
757
- );
758
- }),
759
- );
760
- }
761
-
762
- async function removeWorktree(
763
- run: Run,
764
- root: string,
765
- worktree: string,
766
- ): Promise<StepResult> {
767
- const removed = await run(
768
- 'git',
769
- ['worktree', 'remove', '--force', worktree],
770
- root,
771
- );
772
- await rm(worktree, { recursive: true, force: true }).catch(() => undefined);
773
- await run('git', ['worktree', 'prune'], root);
774
- const gone = !(await exists(worktree));
775
- return {
776
- ok: gone,
777
- output: gone
778
- ? ''
779
- : `The worktree at ${worktree} is still there: ${removed.output}`,
780
- };
781
- }
782
-
783
- function pushDestination(git: GitDeliveryPlan): string {
784
- if (git.deliveryMode === 'fork') {
785
- return githubPushUrl(git.forkOwner!, git.repository!);
786
- }
787
- return git.remote;
788
- }
789
-
790
- async function remoteBranchOwner(
791
- run: Run,
792
- cwd: string,
793
- destination: string,
794
- branch: string,
795
- ): Promise<{
796
- readonly exists: boolean;
797
- readonly message: string;
798
- readonly commit: string | null;
799
- }> {
800
- const listed = await run(
801
- 'git',
802
- ['ls-remote', '--heads', destination, `refs/heads/${branch}`],
803
- cwd,
804
- );
805
- if (listed.code !== 0) {
806
- throw new SandboxSetupError(
807
- 'GIT_REMOTE_UNREACHABLE',
808
- `The push repository could not be checked before delivery. ${listed.output.trim()}`.trim(),
809
- );
810
- }
811
- const commit = listed.output.trim().split(/\s+/)[0];
812
- if (!commit) return { exists: false, message: '', commit: null };
813
- const fetched = await run(
814
- 'git',
815
- ['fetch', '--quiet', destination, branch],
816
- cwd,
817
- );
818
- if (fetched.code !== 0) {
819
- throw new SandboxSetupError(
820
- 'GIT_BRANCH_CHECK_FAILED',
821
- `The existing ${branch} branch could not be inspected.`,
822
- );
823
- }
824
- const message = await outputValue(
825
- run,
826
- 'git',
827
- ['show', '-s', '--format=%B', 'FETCH_HEAD'],
828
- cwd,
829
- );
830
- return { exists: true, message: message ?? '', commit };
831
- }
832
-
833
- function assertSessionBranch(
834
- branch: string,
835
- message: string,
836
- sessionId: string,
837
- ): void {
838
- if (message.includes(`Session ${sessionId}.`)) return;
839
- throw new SandboxSetupError(
840
- 'GIT_BRANCH_CONFLICT',
841
- `The ${branch} branch already exists but was not created for this sandbox session. Choose another branch prefix or remove the conflicting branch yourself.`,
842
- );
843
- }
844
-
845
- async function openPullRequest(
846
- run: Run,
847
- worktree: string,
848
- git: GitDeliveryPlan,
849
- repository: string | null,
850
- title: string,
851
- body: string,
852
- recorder: StepRecorder,
853
- ): Promise<string | null> {
854
- const repositoryArgs = repository ? ['--repo', repository] : [];
855
- const existing = await run(
856
- 'gh',
857
- [
858
- 'pr',
859
- 'list',
860
- ...repositoryArgs,
861
- '--head',
862
- git.branch,
863
- '--state',
864
- 'open',
865
- '--limit',
866
- '1',
867
- '--json',
868
- 'url,headRepositoryOwner',
869
- '--jq',
870
- git.deliveryMode === 'fork'
871
- ? `.[] | select(.headRepositoryOwner.login == ${JSON.stringify(git.forkOwner)}) | .url`
872
- : '.[0].url',
873
- ],
874
- worktree,
875
- );
876
- const existingUrl = existing.code === 0 ? urlIn(existing.output) : null;
877
- if (existingUrl) {
878
- recorder.record('pr', {
879
- ok: true,
880
- output: `A pull request for ${git.branch} is already open; the push updated it.`,
881
- detail: existingUrl,
882
- });
883
- return existingUrl;
884
- }
885
- const created = await withTemporaryFile(body, (file) =>
886
- run(
887
- 'gh',
888
- [
889
- 'pr',
890
- 'create',
891
- ...repositoryArgs,
892
- '--base',
893
- git.baseBranch,
894
- '--head',
895
- git.head,
896
- '--title',
897
- title,
898
- '--body-file',
899
- file,
900
- ...(git.reviewers.length > 0
901
- ? ['--reviewer', git.reviewers.join(',')]
902
- : []),
903
- ],
904
- worktree,
905
- ),
906
- );
907
- const url = created.code === 0 ? urlIn(created.output) : null;
908
- recorder.record('pr', {
909
- ok: url !== null,
910
- output: created.output,
911
- detail: url ?? '',
912
- });
913
- return url;
914
- }
915
-
916
- /* The change lands as a commit on a session branch, built in a separate
917
- worktree so the operator's working tree and index are never touched, and
918
- is pushed with a pull request when a provider is usable. */
919
- export function createGitPullRequestDeliveryTarget(): DeliveryTarget {
920
- return {
921
- id: 'git-pr',
922
-
923
- available: async (context) => {
924
- const config = context.delivery ?? DEFAULT_DELIVERY_CONFIGURATION;
925
- const root = context.workspaceRoot;
926
- const run = await gitDeliveryRunner(context);
927
- const head = await run(
928
- 'git',
929
- ['rev-parse', '--verify', '--quiet', 'HEAD'],
930
- root,
931
- );
932
- if (head.code === null) {
933
- return unavailable('git is not installed or not on PATH.');
934
- }
935
- if (head.code !== 0) {
936
- const inside = await run(
937
- 'git',
938
- ['rev-parse', '--is-inside-work-tree'],
939
- root,
940
- );
941
- return unavailable(
942
- inside.output.trim() === 'true'
943
- ? NO_COMMITS
944
- : 'this workspace is not inside a git work tree.',
945
- );
946
- }
947
- try {
948
- await assertWorktreeDirectoryIgnored(run, root);
949
- } catch (error) {
950
- return unavailable(
951
- error instanceof Error
952
- ? error.message
953
- : 'the delivery worktree directory is not ignored by Git.',
954
- );
955
- }
956
- const remote = await run(
957
- 'git',
958
- ['remote', 'get-url', config.git.remote],
959
- root,
960
- );
961
- if (remote.code !== 0) {
962
- return unavailable(
963
- `the ${config.git.remote} remote is not configured; add it with git remote add ${config.git.remote} <url>.`,
964
- );
965
- }
966
- const base = await run(
967
- 'git',
968
- [
969
- 'ls-remote',
970
- '--exit-code',
971
- '--heads',
972
- config.git.remote,
973
- `refs/heads/${config.git.baseBranch}`,
974
- ],
975
- root,
976
- );
977
- if (base.code !== 0) {
978
- return unavailable(
979
- `the ${config.git.remote}/${config.git.baseBranch} base branch does not exist or cannot be read.`,
980
- );
981
- }
982
- const detectedRepository = githubRepository(remote.output.trim());
983
- if (
984
- config.git.repository !== null &&
985
- detectedRepository !== null &&
986
- config.git.repository.toLowerCase() !== detectedRepository.toLowerCase()
987
- ) {
988
- return unavailable(
989
- `the ${config.git.remote} remote points to ${detectedRepository}, not ${config.git.repository}.`,
990
- );
991
- }
992
- const provider = await probeProvider(run, config, root);
993
- try {
994
- await resolveDestination(
995
- run,
996
- config,
997
- root,
998
- remote.output.trim(),
999
- provider.kind,
1000
- branchNameFor(config, context.session),
1001
- );
1002
- } catch (error) {
1003
- return unavailable(
1004
- error instanceof Error
1005
- ? error.message
1006
- : 'the configured Git destination is not usable.',
1007
- );
1008
- }
1009
- return { available: true, reason: null };
1010
- },
1011
-
1012
- plan: async (context) => {
1013
- assertEjectCapability(context);
1014
- const config = context.delivery ?? DEFAULT_DELIVERY_CONFIGURATION;
1015
- const root = context.workspaceRoot;
1016
- const modules = await planSessionModules(context);
1017
- const primary = modules[0]!;
1018
- const [ownership, budgets] = await Promise.all([
1019
- loadPathOwnership(root),
1020
- loadTaskBudgets(root),
1021
- ]);
1022
- const budget = budgetFor(config, budgets, context.session.kind);
1023
- const enable = modules.some((module) => module.enable);
1024
- const newDependencies = [
1025
- ...new Set(modules.flatMap((module) => module.newPackages)),
1026
- ].sort();
1027
- /* module enable writes the four composition files; install rewrites
1028
- the lockfile whenever a package is new to the workspace. */
1029
- const compositionFiles = enable ? 5 : newDependencies.length > 0 ? 1 : 0;
1030
- const changedFiles = countChangedFiles(modules) + compositionFiles;
1031
- const owners = ownersOf(ownership, [
1032
- ...modules.map((module) => `modules/${module.directory}/module.json`),
1033
- ...(enable ? ['platform/package.json'] : []),
1034
- ]);
1035
- const run = await gitDeliveryRunner(context);
1036
- const remoteUrl = (
1037
- await run('git', ['remote', 'get-url', config.git.remote], root)
1038
- ).output.trim();
1039
- const branch = branchNameFor(config, context.session);
1040
- const provider = await probeProvider(run, config, root);
1041
- const destination = await resolveDestination(
1042
- run,
1043
- config,
1044
- root,
1045
- remoteUrl,
1046
- provider.kind,
1047
- branch,
1048
- );
1049
- return {
1050
- target: 'git-pr',
1051
- deliveredBy: 'git-pr',
1052
- moduleId: primary.id,
1053
- targetPath: primary.targetPath,
1054
- files: primary.files,
1055
- overwrites: primary.overwrites,
1056
- removes: primary.removes,
1057
- newPackages: primary.newPackages,
1058
- enable: primary.enable,
1059
- modules,
1060
- changedFiles,
1061
- gates: [...GATES],
1062
- platformLocal: false,
1063
- restartRequired: false,
1064
- notes: [
1065
- `Nothing in this workspace changes. The modules are committed on ${branch} from ${config.git.remote}/${config.git.baseBranch} and pushed. ${
1066
- provider.kind === 'github'
1067
- ? 'A pull request is opened for review.'
1068
- : provider.note
1069
- }`,
1070
- ],
1071
- git: {
1072
- remote: config.git.remote,
1073
- repository: destination.repository,
1074
- baseBranch: config.git.baseBranch,
1075
- branch,
1076
- deliveryMode: destination.mode,
1077
- pushTarget:
1078
- destination.mode === 'fork'
1079
- ? `${destination.forkOwner}/${githubRepositoryName(destination.repository!)}`
1080
- : config.git.remote,
1081
- forkOwner: destination.forkOwner,
1082
- forkRequired: destination.forkRequired,
1083
- head: destination.head,
1084
- worktreePath: join(worktreesDirectory(root), context.session.id),
1085
- allowedPaths: [
1086
- ...modules.map((module) => `modules/${module.directory}/**`),
1087
- ...(enable ? COMPOSITION_PATHS : []),
1088
- LOCKFILE_PATH,
1089
- ],
1090
- budget,
1091
- changedFiles,
1092
- newDependencies,
1093
- owners,
1094
- requireReviewer: ownership.requireReviewer,
1095
- reviewers: config.git.reviewers,
1096
- guardrails: evaluateGuardrails({
1097
- offending: [],
1098
- changedFiles,
1099
- newDependencies,
1100
- budget,
1101
- }),
1102
- provider: provider.kind,
1103
- providerNote: provider.note,
1104
- compareUrl: destination.compareUrl,
1105
- },
1106
- applied: false,
1107
- };
1108
- },
1109
-
1110
- apply: async (context, plan, emit) => {
1111
- assertEjectCapability(context);
1112
- const git = plan.git;
1113
- if (!git) {
1114
- throw new SandboxSetupError(
1115
- 'EJECT_PLAN_INVALID',
1116
- 'The plan was not prepared for the git-pr target.',
1117
- );
1118
- }
1119
- const session = context.session;
1120
- if (inFlight.has(session.id)) {
1121
- throw new SandboxSetupError(
1122
- 'EJECT_IN_PROGRESS',
1123
- 'A delivery of this session is already running.',
1124
- );
1125
- }
1126
- inFlight.add(session.id);
1127
- const root = context.workspaceRoot;
1128
- const worktree = join(root, git.worktreePath);
1129
- const run = await gitDeliveryRunner(context);
1130
- const recorder = createStepRecorder(emit);
1131
- const { record } = recorder;
1132
- const assertOk = (id: string, result: CommandResult): void => {
1133
- if (result.code !== 0) record(id, step(result));
1134
- };
1135
- let worktreeAdded = false;
1136
- let localBranchCommit: string | null = null;
1137
- let branchPrepared = false;
1138
- let remoteBranchExisted = false;
1139
- let remoteBranchCommit: string | null = null;
1140
- let pushedCommit: string | null = null;
1141
- let pushed = false;
1142
- let delivered = false;
1143
- try {
1144
- /* Re-check the approved spec hash and every planned file before any
1145
- fork, worktree, branch, or remote mutation. */
1146
- assertPlanStillCurrent(plan.modules, await planSessionModules(context));
1147
- await assertWorktreeDirectoryIgnored(run, root);
1148
- const gates = await runDeliveryGates(context, plan.gates, emit);
1149
- const destination = pushDestination(git);
1150
-
1151
- if (git.deliveryMode === 'fork') {
1152
- emit('fork.started', {});
1153
- if (git.forkRequired) {
1154
- const signedInOwner = await outputValue(
1155
- run,
1156
- 'gh',
1157
- ['api', 'user', '--jq', '.login'],
1158
- root,
1159
- );
1160
- if (!signedInOwner) {
1161
- throw new SandboxSetupError(
1162
- 'GITHUB_IDENTITY_UNAVAILABLE',
1163
- 'GitHub did not return the signed-in account before fork creation.',
1164
- );
1165
- }
1166
- const organization =
1167
- signedInOwner.toLowerCase() === git.forkOwner!.toLowerCase()
1168
- ? []
1169
- : ['--org', git.forkOwner!];
1170
- record(
1171
- 'fork',
1172
- step(
1173
- await run(
1174
- 'gh',
1175
- [
1176
- 'repo',
1177
- 'fork',
1178
- git.repository!,
1179
- '--clone=false',
1180
- ...organization,
1181
- ],
1182
- root,
1183
- ),
1184
- git.pushTarget,
1185
- ),
1186
- );
1187
- } else {
1188
- record('fork', {
1189
- ok: true,
1190
- output: '',
1191
- detail: `${git.pushTarget} already exists`,
1192
- });
1193
- }
1194
- }
1195
-
1196
- const remoteOwner = await remoteBranchOwner(
1197
- run,
1198
- root,
1199
- destination,
1200
- git.branch,
1201
- );
1202
- remoteBranchExisted = remoteOwner.exists;
1203
- remoteBranchCommit = remoteOwner.commit;
1204
- if (remoteOwner.exists) {
1205
- assertSessionBranch(git.branch, remoteOwner.message, session.id);
1206
- }
1207
-
1208
- emit('fetch.started', {});
1209
- record(
1210
- 'fetch',
1211
- step(
1212
- await run(
1213
- 'git',
1214
- ['fetch', '--quiet', git.remote, git.baseBranch],
1215
- root,
1216
- ),
1217
- `${git.remote}/${git.baseBranch}`,
1218
- ),
1219
- );
1220
-
1221
- emit('worktree.started', {});
1222
- await mkdir(join(root, worktreesDirectory(root)), { recursive: true });
1223
- if (await exists(worktree)) await removeWorktree(run, root, worktree);
1224
- record(
1225
- 'worktree',
1226
- step(
1227
- await run(
1228
- 'git',
1229
- [
1230
- 'worktree',
1231
- 'add',
1232
- '--detach',
1233
- '--quiet',
1234
- worktree,
1235
- `${git.remote}/${git.baseBranch}`,
1236
- ],
1237
- root,
1238
- ),
1239
- git.worktreePath,
1240
- ),
1241
- );
1242
- worktreeAdded = true;
1243
-
1244
- emit('branch.started', {});
1245
- const localBranch = await run(
1246
- 'git',
1247
- ['show-ref', '--verify', '--quiet', `refs/heads/${git.branch}`],
1248
- root,
1249
- );
1250
- if (localBranch.code === 0) {
1251
- localBranchCommit = await outputValue(
1252
- run,
1253
- 'git',
1254
- ['rev-parse', git.branch],
1255
- root,
1256
- );
1257
- const localMessage =
1258
- (await outputValue(
1259
- run,
1260
- 'git',
1261
- ['show', '-s', '--format=%B', git.branch],
1262
- root,
1263
- )) ?? '';
1264
- assertSessionBranch(git.branch, localMessage, session.id);
1265
- }
1266
- record(
1267
- 'branch',
1268
- step(
1269
- await run('git', ['switch', '--quiet', '-C', git.branch], worktree),
1270
- git.branch,
1271
- ),
1272
- );
1273
- branchPrepared = true;
1274
-
1275
- const paths = sessionPaths(root, session.id, session.moduleSuffix);
1276
- const { copied, removed } = await stageModules(
1277
- paths,
1278
- worktree,
1279
- plan.modules,
1280
- emit,
1281
- );
1282
-
1283
- emit('install.started', {});
1284
- record('install', await installWorktree(run, worktree));
1285
-
1286
- let enabled = false;
1287
- for (const module of plan.modules) {
1288
- if (!module.enable) continue;
1289
- emit('enable.started', { moduleId: module.id });
1290
- record('enable', await enableModule(run, worktree, module.id));
1291
- enabled = true;
1292
- }
1293
-
1294
- emit('verify.started', {});
1295
- record('verify', await verifyPlatform(run, worktree));
1296
-
1297
- emit('guardrails.started', {});
1298
- const status = await run(
1299
- 'git',
1300
- ['status', '--porcelain', '-uall'],
1301
- worktree,
1302
- );
1303
- assertOk('guardrails', status);
1304
- const changed = porcelainPaths(status.output);
1305
- const offending = changed.filter(
1306
- (path) =>
1307
- !git.allowedPaths.some((pattern) => matchesPath(path, pattern)),
1308
- );
1309
- const guardrails = evaluateGuardrails({
1310
- offending,
1311
- changedFiles: changed.length,
1312
- newDependencies: git.newDependencies,
1313
- budget: git.budget,
1314
- });
1315
- record('guardrails', {
1316
- ok: guardrails.ok,
1317
- output: guardrails.ok
1318
- ? changed.join('\n')
1319
- : guardrails.reasons.join('\n'),
1320
- detail: `${changed.length} of ${git.budget.maxChangedFiles} files`,
1321
- });
1322
-
1323
- emit('commit.started', {});
1324
- const pathspecs: string[] = [];
1325
- for (const pattern of git.allowedPaths) {
1326
- const pathspec = pathspecOf(pattern);
1327
- if (await exists(join(worktree, pathspec))) pathspecs.push(pathspec);
1328
- }
1329
- assertOk(
1330
- 'commit',
1331
- await run('git', ['add', '-A', '--', ...pathspecs], worktree),
1332
- );
1333
- const changes = await stagedChanges(run, worktree);
1334
- const owners = ownersOf(await loadPathOwnership(root), [
1335
- ...changes.added,
1336
- ...changes.modified,
1337
- ...changes.removed,
1338
- ]);
1339
- record(
1340
- 'commit',
1341
- step(
1342
- await withTemporaryFile(
1343
- commitMessage(session, plan.modules, gates),
1344
- (file) =>
1345
- run('git', ['commit', '--quiet', '--file', file], worktree),
1346
- ),
1347
- `${changes.added.length + changes.modified.length + changes.removed.length} files on ${git.branch}`,
1348
- ),
1349
- );
1350
-
1351
- emit('push.started', {});
1352
- record(
1353
- 'push',
1354
- step(
1355
- await run(
1356
- 'git',
1357
- [
1358
- 'push',
1359
- '--quiet',
1360
- '-u',
1361
- `--force-with-lease=refs/heads/${git.branch}:${remoteOwner.commit ?? ''}`,
1362
- destination,
1363
- git.branch,
1364
- ],
1365
- worktree,
1366
- ),
1367
- `${git.pushTarget}/${git.branch}`,
1368
- ),
1369
- );
1370
- pushed = true;
1371
- pushedCommit = await outputValue(
1372
- run,
1373
- 'git',
1374
- ['rev-parse', 'HEAD'],
1375
- worktree,
1376
- );
1377
-
1378
- emit('pr.started', {});
1379
- const body = pullRequestBody({
1380
- session,
1381
- modules: plan.modules,
1382
- gates,
1383
- changes,
1384
- owners,
1385
- requireReviewer: git.requireReviewer,
1386
- handoff: await lastReviewHandoff(root, session),
1387
- specs: await specificationEvidence(root, session),
1388
- });
1389
- let pullRequestUrl: string | null = null;
1390
- if (git.provider === 'github') {
1391
- pullRequestUrl = await openPullRequest(
1392
- run,
1393
- worktree,
1394
- git,
1395
- git.repository,
1396
- pullRequestTitle(plan.modules),
1397
- body,
1398
- recorder,
1399
- );
1400
- } else {
1401
- record('pr', {
1402
- ok: true,
1403
- output: body,
1404
- detail: git.compareUrl ?? `${git.remote}/${git.branch}`,
1405
- });
1406
- }
1407
- delivered = true;
1408
-
1409
- return {
1410
- moduleId: plan.moduleId,
1411
- targetPath: plan.targetPath,
1412
- files: copied,
1413
- removed,
1414
- enabled,
1415
- gates,
1416
- steps: recorder.steps,
1417
- restartRequired: false,
1418
- branch: git.branch,
1419
- pullRequestUrl,
1420
- compareUrl: git.compareUrl,
1421
- };
1422
- } finally {
1423
- inFlight.delete(session.id);
1424
- const problems: string[] = [];
1425
- if (worktreeAdded) {
1426
- const result = await removeWorktree(run, root, worktree);
1427
- if (!result.ok) problems.push(result.output);
1428
- }
1429
- if (branchPrepared && !delivered) {
1430
- const result = localBranchCommit
1431
- ? await run(
1432
- 'git',
1433
- ['branch', '-f', git.branch, localBranchCommit],
1434
- root,
1435
- )
1436
- : await run('git', ['branch', '-D', git.branch], root);
1437
- if (result.code !== 0) problems.push(result.output);
1438
- }
1439
- if (pushed && !delivered) {
1440
- const result = remoteBranchExisted
1441
- ? await run(
1442
- 'git',
1443
- [
1444
- 'push',
1445
- '--quiet',
1446
- `--force-with-lease=refs/heads/${git.branch}:${pushedCommit ?? ''}`,
1447
- pushDestination(git),
1448
- `${remoteBranchCommit ?? ''}:refs/heads/${git.branch}`,
1449
- ],
1450
- root,
1451
- )
1452
- : await run(
1453
- 'git',
1454
- [
1455
- 'push',
1456
- '--quiet',
1457
- pushDestination(git),
1458
- '--delete',
1459
- git.branch,
1460
- ],
1461
- root,
1462
- );
1463
- if (result.code !== 0) problems.push(result.output);
1464
- }
1465
- emit('cleanup.completed', {
1466
- ok: problems.length === 0,
1467
- output: problems.join('\n'),
1468
- detail: delivered ? `${git.branch} kept` : '',
1469
- });
1470
- }
1471
- },
1472
- };
1473
- }