@flowdular/sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.ai/skills/test-hardening/SKILL.md +1 -1
  2. package/README.md +69 -12
  3. package/assets/flowdular-banner.png +0 -0
  4. package/modules/agents/package.json +1 -1
  5. package/modules/auth/package.json +1 -1
  6. package/modules/auth/src/client/AuthenticationCore.tsrx +3 -1
  7. package/modules/auth/src/server/endpoints.ts +3 -1
  8. package/modules/auth/src/server/runtime.ts +4 -0
  9. package/modules/automations/package.json +1 -1
  10. package/modules/automations-workflows-integration/package.json +1 -1
  11. package/modules/profile/package.json +1 -1
  12. package/modules/sandbox/package.json +1 -1
  13. package/modules/system/package.json +1 -1
  14. package/modules/users/package.json +1 -1
  15. package/modules/workflows/package.json +1 -1
  16. package/package.json +5 -13
  17. package/packages/ai-provider/package.json +1 -1
  18. package/packages/cli-protocol/package.json +1 -1
  19. package/packages/client/package.json +1 -1
  20. package/packages/client/src/routing.ts +39 -2
  21. package/packages/client/src/shell/BrandMark.tsrx +5 -1
  22. package/packages/client/src/shell/navigation.ts +3 -1
  23. package/packages/client/src/state.ts +6 -2
  24. package/packages/contracts/package.json +1 -1
  25. package/packages/contracts/schemas/project.schema.json +16 -42
  26. package/packages/database/package.json +1 -1
  27. package/packages/database-pglite/package.json +1 -1
  28. package/packages/database-testing/package.json +1 -1
  29. package/packages/dev-console/package.json +1 -1
  30. package/packages/harness/package.json +1 -1
  31. package/packages/kernel/package.json +1 -1
  32. package/packages/server/package.json +1 -1
  33. package/packages/server/src/application-routes.ts +80 -8
  34. package/packages/server/src/index.ts +4 -1
  35. package/packages/server/src/web-html.ts +9 -5
  36. package/packages/server/src/web.ts +59 -7
  37. package/packages/ui/package.json +1 -1
  38. package/packages/coding-agent/package.json +0 -44
  39. package/packages/coding-agent/src/drivers/byok.ts +0 -322
  40. package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
  41. package/packages/coding-agent/src/drivers/codex.ts +0 -281
  42. package/packages/coding-agent/src/index.ts +0 -56
  43. package/packages/coding-agent/src/registry.ts +0 -119
  44. package/packages/coding-agent/src/roles/contract.ts +0 -60
  45. package/packages/coding-agent/src/roles/defaults.ts +0 -134
  46. package/packages/coding-agent/src/roles/handoff.ts +0 -41
  47. package/packages/coding-agent/src/roles/registry.ts +0 -156
  48. package/packages/coding-agent/src/roles/skills.ts +0 -72
  49. package/packages/coding-agent/src/roles/sync.ts +0 -88
  50. package/packages/coding-agent/src/types.ts +0 -102
  51. package/packages/coding-agent/src/workspace.ts +0 -275
  52. package/packages/sandbox/README.md +0 -691
  53. package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
  54. package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
  55. package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
  56. package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
  57. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  58. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  59. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  60. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  61. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  62. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  63. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  64. package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  65. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  66. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  67. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  68. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  69. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  70. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  71. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  72. package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  73. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  74. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  75. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  76. package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  77. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  78. package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  79. package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  80. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  81. package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  82. package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  83. package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
  84. package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
  85. package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
  86. package/packages/sandbox/dist/client/favicon.svg +0 -1
  87. package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
  88. package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
  89. package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
  90. package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
  91. package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
  92. package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
  93. package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
  94. package/packages/sandbox/dist/server/entry.js +0 -91671
  95. package/packages/sandbox/dist/server/favicon.svg +0 -1
  96. package/packages/sandbox/dist/server/index.html +0 -161
  97. package/packages/sandbox/index.html +0 -159
  98. package/packages/sandbox/octane.config.ts +0 -36
  99. package/packages/sandbox/package.json +0 -83
  100. package/packages/sandbox/public/favicon.svg +0 -1
  101. package/packages/sandbox/public/og.png +0 -0
  102. package/packages/sandbox/src/App.tsrx +0 -924
  103. package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
  104. package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
  105. package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
  106. package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
  107. package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
  108. package/packages/sandbox/src/client/GateResults.tsrx +0 -60
  109. package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
  110. package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
  111. package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
  112. package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
  113. package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
  114. package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
  115. package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
  116. package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
  117. package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
  118. package/packages/sandbox/src/client/api.ts +0 -797
  119. package/packages/sandbox/src/client/attachments.ts +0 -73
  120. package/packages/sandbox/src/client/dashboard.ts +0 -46
  121. package/packages/sandbox/src/client/github-settings.ts +0 -48
  122. package/packages/sandbox/src/client/highlight.ts +0 -105
  123. package/packages/sandbox/src/client/i18n.ts +0 -32
  124. package/packages/sandbox/src/client/locales/en.json +0 -466
  125. package/packages/sandbox/src/client/locales/pl.json +0 -466
  126. package/packages/sandbox/src/client/session-labels.ts +0 -77
  127. package/packages/sandbox/src/client/state.ts +0 -108
  128. package/packages/sandbox/src/index.ts +0 -10
  129. package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
  130. package/packages/sandbox/src/preview/load-module.ts +0 -16
  131. package/packages/sandbox/src/server/attachments.ts +0 -308
  132. package/packages/sandbox/src/server/auto-review.ts +0 -181
  133. package/packages/sandbox/src/server/checkpoints.ts +0 -90
  134. package/packages/sandbox/src/server/config.ts +0 -460
  135. package/packages/sandbox/src/server/dashboard.ts +0 -245
  136. package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
  137. package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
  138. package/packages/sandbox/src/server/delivery/index.ts +0 -83
  139. package/packages/sandbox/src/server/delivery/local.ts +0 -151
  140. package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
  141. package/packages/sandbox/src/server/delivery/plan.ts +0 -218
  142. package/packages/sandbox/src/server/delivery/policies.ts +0 -239
  143. package/packages/sandbox/src/server/delivery/record.ts +0 -41
  144. package/packages/sandbox/src/server/delivery/steps.ts +0 -464
  145. package/packages/sandbox/src/server/delivery/types.ts +0 -158
  146. package/packages/sandbox/src/server/dependencies.ts +0 -88
  147. package/packages/sandbox/src/server/diff.ts +0 -230
  148. package/packages/sandbox/src/server/gates.ts +0 -388
  149. package/packages/sandbox/src/server/index.ts +0 -139
  150. package/packages/sandbox/src/server/path-guard.ts +0 -282
  151. package/packages/sandbox/src/server/planning.ts +0 -749
  152. package/packages/sandbox/src/server/platform-client.ts +0 -189
  153. package/packages/sandbox/src/server/preview-database-host.ts +0 -340
  154. package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
  155. package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
  156. package/packages/sandbox/src/server/preview-database.ts +0 -109
  157. package/packages/sandbox/src/server/preview-ipc.ts +0 -15
  158. package/packages/sandbox/src/server/preview-modules.ts +0 -83
  159. package/packages/sandbox/src/server/preview-revision.ts +0 -44
  160. package/packages/sandbox/src/server/preview-runtime.ts +0 -467
  161. package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
  162. package/packages/sandbox/src/server/preview-worker.ts +0 -168
  163. package/packages/sandbox/src/server/reference.ts +0 -251
  164. package/packages/sandbox/src/server/routes.ts +0 -2258
  165. package/packages/sandbox/src/server/runtime.ts +0 -188
  166. package/packages/sandbox/src/server/session-owner.ts +0 -23
  167. package/packages/sandbox/src/server/sessions.ts +0 -849
  168. package/packages/sandbox/src/server/spec.ts +0 -471
  169. package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
  170. package/packages/sandbox/src/server/turns.ts +0 -993
  171. package/packages/sandbox/src/server/workspace-install.ts +0 -266
  172. package/packages/sandbox/src/server/workspace-root.ts +0 -73
  173. package/packages/sandbox/src/styles.css +0 -1573
  174. package/packages/sandbox/vite.config.ts +0 -133
@@ -1,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
- }