task-while 0.0.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/bin/task-while.mjs +22 -0
  4. package/package.json +72 -0
  5. package/src/agents/claude.ts +175 -0
  6. package/src/agents/codex.ts +231 -0
  7. package/src/agents/provider-options.ts +45 -0
  8. package/src/agents/types.ts +69 -0
  9. package/src/batch/config.ts +109 -0
  10. package/src/batch/discovery.ts +35 -0
  11. package/src/batch/provider.ts +79 -0
  12. package/src/commands/batch.ts +266 -0
  13. package/src/commands/run.ts +270 -0
  14. package/src/core/engine-helpers.ts +114 -0
  15. package/src/core/engine-outcomes.ts +166 -0
  16. package/src/core/engine.ts +223 -0
  17. package/src/core/orchestrator-helpers.ts +52 -0
  18. package/src/core/orchestrator-integrate-resume.ts +149 -0
  19. package/src/core/orchestrator-review-resume.ts +228 -0
  20. package/src/core/orchestrator-task-attempt.ts +257 -0
  21. package/src/core/orchestrator.ts +99 -0
  22. package/src/core/runtime.ts +175 -0
  23. package/src/core/task-topology.ts +85 -0
  24. package/src/index.ts +121 -0
  25. package/src/prompts/implementer.ts +18 -0
  26. package/src/prompts/reviewer.ts +26 -0
  27. package/src/runtime/fs-runtime.ts +209 -0
  28. package/src/runtime/git.ts +137 -0
  29. package/src/runtime/github-pr-snapshot-decode.ts +307 -0
  30. package/src/runtime/github-pr-snapshot-queries.ts +137 -0
  31. package/src/runtime/github-pr-snapshot.ts +139 -0
  32. package/src/runtime/github.ts +232 -0
  33. package/src/runtime/path-layout.ts +13 -0
  34. package/src/runtime/workspace-resolver.ts +125 -0
  35. package/src/schema/index.ts +127 -0
  36. package/src/schema/model.ts +233 -0
  37. package/src/schema/shared.ts +93 -0
  38. package/src/task-sources/openspec/cli-json.ts +79 -0
  39. package/src/task-sources/openspec/context-files.ts +121 -0
  40. package/src/task-sources/openspec/parse-tasks-md.ts +57 -0
  41. package/src/task-sources/openspec/session.ts +235 -0
  42. package/src/task-sources/openspec/source.ts +59 -0
  43. package/src/task-sources/registry.ts +22 -0
  44. package/src/task-sources/spec-kit/parse-tasks-md.ts +48 -0
  45. package/src/task-sources/spec-kit/session.ts +174 -0
  46. package/src/task-sources/spec-kit/source.ts +30 -0
  47. package/src/task-sources/types.ts +47 -0
  48. package/src/types.ts +29 -0
  49. package/src/utils/fs.ts +31 -0
  50. package/src/workflow/config.ts +127 -0
  51. package/src/workflow/direct-preset.ts +44 -0
  52. package/src/workflow/finalize-task-checkbox.ts +24 -0
  53. package/src/workflow/preset.ts +86 -0
  54. package/src/workflow/pull-request-preset.ts +312 -0
  55. package/src/workflow/remote-reviewer.ts +243 -0
@@ -0,0 +1,127 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { parse } from 'yaml'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ claudeProviderOptionsSchema,
9
+ codexProviderOptionsSchema,
10
+ type WorkflowRoleProviderOptions,
11
+ } from '../agents/provider-options'
12
+
13
+ const workflowModeSchema = z.enum(['direct', 'pull-request'])
14
+
15
+ const defaultWorkflowRole: WorkflowRoleProviderOptions = {
16
+ provider: 'codex',
17
+ }
18
+
19
+ const workflowRoleProviderSchema = z.discriminatedUnion('provider', [
20
+ z
21
+ .object({
22
+ provider: z.literal('claude'),
23
+ })
24
+ .extend(claudeProviderOptionsSchema.shape)
25
+ .strict(),
26
+ z
27
+ .object({
28
+ provider: z.literal('codex'),
29
+ })
30
+ .extend(codexProviderOptionsSchema.shape)
31
+ .strict(),
32
+ ])
33
+
34
+ const workflowRoleSchema = z.preprocess((value) => {
35
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
36
+ const role = value as Record<string, unknown>
37
+ if (!('provider' in role)) {
38
+ return {
39
+ ...role,
40
+ provider: 'codex',
41
+ }
42
+ }
43
+ }
44
+ return value
45
+ }, workflowRoleProviderSchema)
46
+
47
+ const workflowRolesSchema = z
48
+ .object({
49
+ implementer: workflowRoleSchema.default(defaultWorkflowRole),
50
+ reviewer: workflowRoleSchema.default(defaultWorkflowRole),
51
+ })
52
+ .strict()
53
+
54
+ const taskConfigSchema = z
55
+ .object({
56
+ maxIterations: z.number().int().min(1).max(20).default(5),
57
+ source: z.string().trim().min(1).default('spec-kit'),
58
+ })
59
+ .strict()
60
+
61
+ const workflowConfigSchema = z
62
+ .object({
63
+ task: taskConfigSchema.default({}),
64
+ workflow: z
65
+ .object({
66
+ mode: workflowModeSchema.default('direct'),
67
+ roles: workflowRolesSchema.default({}),
68
+ })
69
+ .strict()
70
+ .default({}),
71
+ })
72
+ .strict()
73
+ .default({})
74
+
75
+ export type WorkflowMode = 'direct' | 'pull-request'
76
+
77
+ export type WorkflowProvider = WorkflowRoleProviderOptions['provider']
78
+ export type WorkflowRoleConfig = WorkflowRoleProviderOptions
79
+
80
+ export interface WorkflowRolesConfig {
81
+ implementer: WorkflowRoleConfig
82
+ reviewer: WorkflowRoleConfig
83
+ }
84
+
85
+ export interface WorkflowSettingsConfig {
86
+ mode: WorkflowMode
87
+ roles: WorkflowRolesConfig
88
+ }
89
+
90
+ export interface TaskSettingsConfig {
91
+ maxIterations: number
92
+ source: string
93
+ }
94
+
95
+ export interface WorkflowConfig {
96
+ task: TaskSettingsConfig
97
+ workflow: WorkflowSettingsConfig
98
+ }
99
+
100
+ export async function loadWorkflowConfig(
101
+ workspaceRoot: string,
102
+ ): Promise<WorkflowConfig> {
103
+ const configPath = path.join(workspaceRoot, 'while.yaml')
104
+ let rawConfig: unknown = {}
105
+
106
+ try {
107
+ const configSource = await readFile(configPath, 'utf8')
108
+ rawConfig = parse(configSource) ?? {}
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
111
+ throw error
112
+ }
113
+ }
114
+
115
+ const parsedConfig = workflowConfigSchema.parse(rawConfig)
116
+
117
+ return {
118
+ task: {
119
+ maxIterations: parsedConfig.task.maxIterations,
120
+ source: parsedConfig.task.source,
121
+ },
122
+ workflow: {
123
+ mode: parsedConfig.workflow.mode,
124
+ roles: parsedConfig.workflow.roles,
125
+ },
126
+ }
127
+ }
@@ -0,0 +1,44 @@
1
+ import { finalizeTaskCheckbox } from './finalize-task-checkbox'
2
+
3
+ import type { ReviewerProvider } from '../agents/types'
4
+ import type { DirectWorkflowPreset } from './preset'
5
+
6
+ export interface CreateDirectWorkflowPresetInput {
7
+ reviewer: ReviewerProvider
8
+ }
9
+
10
+ export function createDirectWorkflowPreset(
11
+ input: CreateDirectWorkflowPresetInput,
12
+ ): DirectWorkflowPreset {
13
+ return {
14
+ mode: 'direct',
15
+ async integrate(context) {
16
+ const { commitSha } = await finalizeTaskCheckbox({
17
+ commitMessage: context.commitMessage,
18
+ runtime: context.runtime,
19
+ taskHandle: context.taskHandle,
20
+ })
21
+ return {
22
+ kind: 'completed',
23
+ result: {
24
+ commitSha,
25
+ summary: 'integrated',
26
+ },
27
+ }
28
+ },
29
+ async review(context) {
30
+ const review = await input.reviewer.review({
31
+ actualChangedFiles: context.actualChangedFiles,
32
+ attempt: context.attempt,
33
+ generation: context.generation,
34
+ implement: context.implement,
35
+ lastFindings: context.lastFindings,
36
+ prompt: context.prompt,
37
+ taskHandle: context.taskHandle,
38
+ })
39
+ return review.verdict === 'pass'
40
+ ? { kind: 'approved', review }
41
+ : { kind: 'rejected', review }
42
+ },
43
+ }
44
+ }
@@ -0,0 +1,24 @@
1
+ import type { OrchestratorRuntime } from '../core/runtime'
2
+
3
+ export interface FinalizeTaskCheckboxInput {
4
+ commitMessage: string
5
+ runtime: OrchestratorRuntime
6
+ taskHandle: string
7
+ }
8
+
9
+ export async function finalizeTaskCheckbox(input: FinalizeTaskCheckboxInput) {
10
+ try {
11
+ await input.runtime.taskSource.applyTaskCompletion(input.taskHandle)
12
+ return await input.runtime.git.commitTask({
13
+ message: input.commitMessage,
14
+ })
15
+ } catch (error) {
16
+ let reason = `Task commit failed: ${error instanceof Error ? error.message : String(error)}`
17
+ try {
18
+ await input.runtime.taskSource.revertTaskCompletion(input.taskHandle)
19
+ } catch (rollbackError) {
20
+ reason = `${reason}; task completion rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
21
+ }
22
+ throw new Error(reason)
23
+ }
24
+ }
@@ -0,0 +1,86 @@
1
+ import type { WorkflowRoleProviders } from '../agents/types'
2
+ import type { OrchestratorRuntime } from '../core/runtime'
3
+ import type { TaskPrompt } from '../task-sources/types'
4
+ import type { ImplementOutput, ReviewFinding, ReviewOutput } from '../types'
5
+
6
+ export type WorkflowMode = 'direct' | 'pull-request'
7
+
8
+ export interface DirectReviewPhaseContext {
9
+ actualChangedFiles: string[]
10
+ attempt: number
11
+ commitMessage: string
12
+ generation: number
13
+ implement: ImplementOutput
14
+ lastFindings: ReviewFinding[]
15
+ prompt: TaskPrompt
16
+ taskHandle: string
17
+ }
18
+
19
+ export interface PullRequestReviewPhaseContext {
20
+ attempt: number
21
+ commitMessage: string
22
+ completionCriteria: string[]
23
+ generation?: number
24
+ implement?: ImplementOutput
25
+ lastFindings?: never[]
26
+ runtime: OrchestratorRuntime
27
+ taskHandle: string
28
+ }
29
+
30
+ export interface ApprovedReviewPhaseResult {
31
+ kind: 'approved'
32
+ review: ReviewOutput
33
+ }
34
+
35
+ export interface RejectedReviewPhaseResult {
36
+ kind: 'rejected'
37
+ review: ReviewOutput
38
+ }
39
+
40
+ export type ReviewPhaseResult =
41
+ | ApprovedReviewPhaseResult
42
+ | RejectedReviewPhaseResult
43
+
44
+ export interface IntegratePhaseContext {
45
+ commitMessage: string
46
+ runtime: OrchestratorRuntime
47
+ taskHandle: string
48
+ }
49
+
50
+ export interface IntegratePhaseResult {
51
+ kind: 'completed'
52
+ result: IntegrateResult
53
+ }
54
+
55
+ export interface IntegrateResult {
56
+ commitSha: string
57
+ summary: string
58
+ }
59
+
60
+ export interface DirectWorkflowPreset {
61
+ integrate: (context: IntegratePhaseContext) => Promise<IntegratePhaseResult>
62
+ readonly mode: 'direct'
63
+ review: (context: DirectReviewPhaseContext) => Promise<ReviewPhaseResult>
64
+ }
65
+
66
+ export interface PullRequestWorkflowPreset {
67
+ integrate: (context: IntegratePhaseContext) => Promise<IntegratePhaseResult>
68
+ readonly mode: 'pull-request'
69
+ review: (context: PullRequestReviewPhaseContext) => Promise<ReviewPhaseResult>
70
+ }
71
+
72
+ export type WorkflowPreset = DirectWorkflowPreset | PullRequestWorkflowPreset
73
+
74
+ export function isPullRequestWorkflowPreset(
75
+ preset: WorkflowPreset,
76
+ ): preset is PullRequestWorkflowPreset {
77
+ return preset.mode === 'pull-request'
78
+ }
79
+
80
+ export interface WorkflowRuntime {
81
+ preset: WorkflowPreset
82
+ roles: WorkflowRoleProviders
83
+ }
84
+
85
+ export { createDirectWorkflowPreset } from './direct-preset'
86
+ export { createPullRequestWorkflowPreset } from './pull-request-preset'
@@ -0,0 +1,312 @@
1
+ import { finalizeTaskCheckbox } from './finalize-task-checkbox'
2
+
3
+ import type {
4
+ PullRequestReviewResult,
5
+ RemoteReviewerProvider,
6
+ } from '../agents/types'
7
+ import type { OrchestratorRuntime, PullRequestRef } from '../core/runtime'
8
+ import type {
9
+ IntegratePhaseResult,
10
+ PullRequestReviewPhaseContext,
11
+ PullRequestWorkflowPreset,
12
+ ReviewPhaseResult,
13
+ } from './preset'
14
+
15
+ const DEFAULT_BASE_BRANCH = 'main'
16
+ const DEFAULT_REVIEW_POLL_INTERVAL_MS = 60_000
17
+
18
+ function toTaskBranchName(commitMessage: string) {
19
+ const slug = commitMessage
20
+ .replace(/^Task\s+/i, '')
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, '-')
23
+ .replace(/^-+|-+$/g, '')
24
+ return `task/${slug}`
25
+ }
26
+
27
+ function createCheckpointCommitMessage(commitMessage: string, attempt: number) {
28
+ return `checkpoint: ${commitMessage} (attempt ${attempt})`
29
+ }
30
+
31
+ function createPullRequestBody(context: PullRequestReviewPhaseContext) {
32
+ return [
33
+ `Task: ${context.commitMessage}`,
34
+ `Attempt: ${context.attempt}`,
35
+ '',
36
+ 'Managed by task-while.',
37
+ ].join('\n')
38
+ }
39
+
40
+ interface EnsureTaskBranchInput {
41
+ branchName: string
42
+ restoreFromRemote: boolean
43
+ runtime: OrchestratorRuntime
44
+ }
45
+
46
+ async function ensureTaskBranch(input: EnsureTaskBranchInput) {
47
+ const currentBranch = await input.runtime.git.getCurrentBranch()
48
+ if (currentBranch === input.branchName) {
49
+ return
50
+ }
51
+ try {
52
+ await input.runtime.git.checkoutBranch(input.branchName)
53
+ } catch {
54
+ if (input.restoreFromRemote) {
55
+ await input.runtime.git.checkoutRemoteBranch(input.branchName)
56
+ return
57
+ }
58
+ await input.runtime.git.checkoutBranch(input.branchName, {
59
+ create: true,
60
+ startPoint: DEFAULT_BASE_BRANCH,
61
+ })
62
+ }
63
+ }
64
+
65
+ interface EnsurePullRequestInput {
66
+ branchName: string
67
+ branchNeedsPush: boolean
68
+ context: PullRequestReviewPhaseContext
69
+ existingPullRequest: null | PullRequestRef
70
+ }
71
+
72
+ async function ensurePullRequest(
73
+ input: EnsurePullRequestInput,
74
+ ): Promise<PullRequestRef> {
75
+ let pullRequest = input.existingPullRequest
76
+
77
+ if (input.branchNeedsPush || !pullRequest) {
78
+ await input.context.runtime.git.pushBranch(input.branchName)
79
+ }
80
+
81
+ if (pullRequest) {
82
+ return pullRequest
83
+ }
84
+
85
+ pullRequest = await input.context.runtime.github.createPullRequest({
86
+ baseBranch: DEFAULT_BASE_BRANCH,
87
+ body: createPullRequestBody(input.context),
88
+ headBranch: input.branchName,
89
+ title: input.context.commitMessage,
90
+ })
91
+ return pullRequest
92
+ }
93
+
94
+ interface WaitForRemoteReviewInput {
95
+ checkpointStartedAt: string
96
+ context: PullRequestReviewPhaseContext
97
+ pullRequest: PullRequestRef
98
+ reviewer: RemoteReviewerProvider
99
+ sleep: SleepFunction
100
+ }
101
+
102
+ type SleepFunction = (ms: number) => Promise<void>
103
+
104
+ async function waitForRemoteReview(
105
+ input: WaitForRemoteReviewInput,
106
+ ): Promise<ReviewPhaseResult> {
107
+ const evaluateReview = async () => {
108
+ const snapshot = await input.context.runtime.github.getPullRequestSnapshot({
109
+ pullRequestNumber: input.pullRequest.number,
110
+ })
111
+ return input.reviewer.evaluatePullRequestReview({
112
+ checkpointStartedAt: input.checkpointStartedAt,
113
+ completionCriteria: input.context.completionCriteria,
114
+ pullRequest: snapshot,
115
+ taskHandle: input.context.taskHandle,
116
+ })
117
+ }
118
+
119
+ let result: PullRequestReviewResult = await evaluateReview()
120
+ while (result.kind === 'pending') {
121
+ await input.sleep(DEFAULT_REVIEW_POLL_INTERVAL_MS)
122
+ result = await evaluateReview()
123
+ }
124
+ return result
125
+ }
126
+
127
+ function toLocalCleanupWarning(error: unknown) {
128
+ return error instanceof Error ? error.message : String(error)
129
+ }
130
+
131
+ interface CleanupAfterMergeInput {
132
+ branchName: string
133
+ runtime: OrchestratorRuntime
134
+ }
135
+
136
+ async function cleanupAfterMerge(input: CleanupAfterMergeInput) {
137
+ const warnings: string[] = []
138
+ let onBaseBranch = false
139
+
140
+ try {
141
+ await input.runtime.git.checkoutBranch(DEFAULT_BASE_BRANCH)
142
+ onBaseBranch = true
143
+ } catch (error) {
144
+ warnings.push(
145
+ `checkout ${DEFAULT_BASE_BRANCH} failed: ${toLocalCleanupWarning(error)}`,
146
+ )
147
+ }
148
+
149
+ if (!onBaseBranch) {
150
+ return warnings
151
+ }
152
+
153
+ try {
154
+ await input.runtime.git.pullFastForward(DEFAULT_BASE_BRANCH)
155
+ } catch (error) {
156
+ warnings.push(
157
+ `pull ${DEFAULT_BASE_BRANCH} failed: ${toLocalCleanupWarning(error)}`,
158
+ )
159
+ }
160
+
161
+ try {
162
+ await input.runtime.git.deleteLocalBranch(input.branchName)
163
+ } catch (error) {
164
+ warnings.push(`delete local branch failed: ${toLocalCleanupWarning(error)}`)
165
+ }
166
+
167
+ return warnings
168
+ }
169
+
170
+ interface SummarizeIntegrateResultInput {
171
+ status: 'already integrated' | 'integrated'
172
+ warnings: string[]
173
+ }
174
+
175
+ function summarizeIntegrateResult(input: SummarizeIntegrateResultInput) {
176
+ return input.warnings.length === 0
177
+ ? input.status
178
+ : `${input.status}; local cleanup warning: ${input.warnings.join('; ')}`
179
+ }
180
+
181
+ export interface CreatePullRequestWorkflowPresetInput {
182
+ reviewer: RemoteReviewerProvider
183
+ sleep?: SleepFunction
184
+ }
185
+
186
+ export function createPullRequestWorkflowPreset(
187
+ input: CreatePullRequestWorkflowPresetInput,
188
+ ): PullRequestWorkflowPreset {
189
+ const sleep =
190
+ input.sleep ??
191
+ (async (ms: number) => {
192
+ await new Promise((resolve) => {
193
+ setTimeout(resolve, ms)
194
+ })
195
+ })
196
+
197
+ return {
198
+ mode: 'pull-request',
199
+ async integrate(context): Promise<IntegratePhaseResult> {
200
+ const branchName = toTaskBranchName(context.commitMessage)
201
+ const openPullRequest =
202
+ await context.runtime.github.findOpenPullRequestByHeadBranch({
203
+ headBranch: branchName,
204
+ })
205
+ if (openPullRequest) {
206
+ await ensureTaskBranch({
207
+ branchName,
208
+ restoreFromRemote: true,
209
+ runtime: context.runtime,
210
+ })
211
+ const taskChecked = await context.runtime.taskSource.isTaskCompleted(
212
+ context.taskHandle,
213
+ )
214
+ if (!taskChecked) {
215
+ await finalizeTaskCheckbox({
216
+ commitMessage: context.commitMessage,
217
+ runtime: context.runtime,
218
+ taskHandle: context.taskHandle,
219
+ })
220
+ }
221
+ await context.runtime.git.pushBranch(branchName)
222
+ const mergeResult = await context.runtime.github.squashMergePullRequest(
223
+ {
224
+ pullRequestNumber: openPullRequest.number,
225
+ subject: context.commitMessage,
226
+ },
227
+ )
228
+ const warnings = await cleanupAfterMerge({
229
+ branchName,
230
+ runtime: context.runtime,
231
+ })
232
+
233
+ return {
234
+ kind: 'completed',
235
+ result: {
236
+ commitSha: mergeResult.commitSha,
237
+ summary: summarizeIntegrateResult({
238
+ status: 'integrated',
239
+ warnings,
240
+ }),
241
+ },
242
+ }
243
+ }
244
+
245
+ const mergedPullRequest =
246
+ await context.runtime.github.findMergedPullRequestByHeadBranch({
247
+ headBranch: branchName,
248
+ })
249
+ if (!mergedPullRequest) {
250
+ throw new Error(
251
+ `Missing open or merged pull request for branch ${branchName}`,
252
+ )
253
+ }
254
+ const warnings = await cleanupAfterMerge({
255
+ branchName,
256
+ runtime: context.runtime,
257
+ })
258
+
259
+ return {
260
+ kind: 'completed',
261
+ result: {
262
+ commitSha: mergedPullRequest.mergeCommitSha,
263
+ summary: summarizeIntegrateResult({
264
+ status: 'already integrated',
265
+ warnings,
266
+ }),
267
+ },
268
+ }
269
+ },
270
+ async review(context): Promise<ReviewPhaseResult> {
271
+ const branchName = toTaskBranchName(context.commitMessage)
272
+ const checkpointMessage = createCheckpointCommitMessage(
273
+ context.commitMessage,
274
+ context.attempt,
275
+ )
276
+ const existingPullRequest =
277
+ await context.runtime.github.findOpenPullRequestByHeadBranch({
278
+ headBranch: branchName,
279
+ })
280
+ await ensureTaskBranch({
281
+ branchName,
282
+ restoreFromRemote: existingPullRequest !== null,
283
+ runtime: context.runtime,
284
+ })
285
+
286
+ const headSubject = await context.runtime.git.getHeadSubject()
287
+ if (headSubject !== checkpointMessage) {
288
+ await context.runtime.git.commitTask({
289
+ message: checkpointMessage,
290
+ })
291
+ }
292
+
293
+ const checkpointStartedAt = await context.runtime.git.getHeadTimestamp()
294
+ const pullRequest = await ensurePullRequest({
295
+ branchName,
296
+ branchNeedsPush: true,
297
+ context,
298
+ existingPullRequest,
299
+ })
300
+
301
+ return waitForRemoteReview({
302
+ checkpointStartedAt,
303
+ context,
304
+ pullRequest,
305
+ reviewer: input.reviewer,
306
+ sleep,
307
+ })
308
+ },
309
+ }
310
+ }
311
+
312
+ export { DEFAULT_REVIEW_POLL_INTERVAL_MS, toTaskBranchName }