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.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/bin/task-while.mjs +22 -0
- package/package.json +72 -0
- package/src/agents/claude.ts +175 -0
- package/src/agents/codex.ts +231 -0
- package/src/agents/provider-options.ts +45 -0
- package/src/agents/types.ts +69 -0
- package/src/batch/config.ts +109 -0
- package/src/batch/discovery.ts +35 -0
- package/src/batch/provider.ts +79 -0
- package/src/commands/batch.ts +266 -0
- package/src/commands/run.ts +270 -0
- package/src/core/engine-helpers.ts +114 -0
- package/src/core/engine-outcomes.ts +166 -0
- package/src/core/engine.ts +223 -0
- package/src/core/orchestrator-helpers.ts +52 -0
- package/src/core/orchestrator-integrate-resume.ts +149 -0
- package/src/core/orchestrator-review-resume.ts +228 -0
- package/src/core/orchestrator-task-attempt.ts +257 -0
- package/src/core/orchestrator.ts +99 -0
- package/src/core/runtime.ts +175 -0
- package/src/core/task-topology.ts +85 -0
- package/src/index.ts +121 -0
- package/src/prompts/implementer.ts +18 -0
- package/src/prompts/reviewer.ts +26 -0
- package/src/runtime/fs-runtime.ts +209 -0
- package/src/runtime/git.ts +137 -0
- package/src/runtime/github-pr-snapshot-decode.ts +307 -0
- package/src/runtime/github-pr-snapshot-queries.ts +137 -0
- package/src/runtime/github-pr-snapshot.ts +139 -0
- package/src/runtime/github.ts +232 -0
- package/src/runtime/path-layout.ts +13 -0
- package/src/runtime/workspace-resolver.ts +125 -0
- package/src/schema/index.ts +127 -0
- package/src/schema/model.ts +233 -0
- package/src/schema/shared.ts +93 -0
- package/src/task-sources/openspec/cli-json.ts +79 -0
- package/src/task-sources/openspec/context-files.ts +121 -0
- package/src/task-sources/openspec/parse-tasks-md.ts +57 -0
- package/src/task-sources/openspec/session.ts +235 -0
- package/src/task-sources/openspec/source.ts +59 -0
- package/src/task-sources/registry.ts +22 -0
- package/src/task-sources/spec-kit/parse-tasks-md.ts +48 -0
- package/src/task-sources/spec-kit/session.ts +174 -0
- package/src/task-sources/spec-kit/source.ts +30 -0
- package/src/task-sources/types.ts +47 -0
- package/src/types.ts +29 -0
- package/src/utils/fs.ts +31 -0
- package/src/workflow/config.ts +127 -0
- package/src/workflow/direct-preset.ts +44 -0
- package/src/workflow/finalize-task-checkbox.ts +24 -0
- package/src/workflow/preset.ts +86 -0
- package/src/workflow/pull-request-preset.ts +312 -0
- package/src/workflow/remote-reviewer.ts +243 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
acceptanceStatusValues,
|
|
5
|
+
dateTimeSchema,
|
|
6
|
+
finalStatusValues,
|
|
7
|
+
findingSeverityValues,
|
|
8
|
+
implementStatusValues,
|
|
9
|
+
nonEmptyStringSchema,
|
|
10
|
+
overallRiskValues,
|
|
11
|
+
reviewVerdictValues,
|
|
12
|
+
runningStageValues,
|
|
13
|
+
taskHandleSchema,
|
|
14
|
+
taskStatusValues,
|
|
15
|
+
uniqueStringArray,
|
|
16
|
+
workflowEventTypeValues,
|
|
17
|
+
} from './shared'
|
|
18
|
+
|
|
19
|
+
export const reviewFindingSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
file: nonEmptyStringSchema.optional(),
|
|
22
|
+
fixHint: nonEmptyStringSchema,
|
|
23
|
+
issue: nonEmptyStringSchema,
|
|
24
|
+
severity: z.enum(findingSeverityValues),
|
|
25
|
+
})
|
|
26
|
+
.strict()
|
|
27
|
+
|
|
28
|
+
export const acceptanceCheckSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
criterion: nonEmptyStringSchema,
|
|
31
|
+
note: nonEmptyStringSchema,
|
|
32
|
+
status: z.enum(acceptanceStatusValues),
|
|
33
|
+
})
|
|
34
|
+
.strict()
|
|
35
|
+
|
|
36
|
+
export const taskTopologyEntrySchema = z
|
|
37
|
+
.object({
|
|
38
|
+
commitSubject: nonEmptyStringSchema,
|
|
39
|
+
dependsOn: uniqueStringArray('dependency task handle'),
|
|
40
|
+
handle: taskHandleSchema,
|
|
41
|
+
})
|
|
42
|
+
.strict()
|
|
43
|
+
|
|
44
|
+
export const taskGraphSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
featureId: nonEmptyStringSchema,
|
|
47
|
+
maxIterations: z.number().int().min(1).max(20),
|
|
48
|
+
tasks: z
|
|
49
|
+
.array(taskTopologyEntrySchema)
|
|
50
|
+
.min(1)
|
|
51
|
+
.superRefine((tasks, ctx) => {
|
|
52
|
+
const seen = new Set<string>()
|
|
53
|
+
for (const [index, task] of tasks.entries()) {
|
|
54
|
+
if (seen.has(task.handle)) {
|
|
55
|
+
ctx.addIssue({
|
|
56
|
+
code: z.ZodIssueCode.custom,
|
|
57
|
+
message: `Duplicate task handle: ${task.handle}`,
|
|
58
|
+
path: [index, 'handle'],
|
|
59
|
+
})
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
seen.add(task.handle)
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
.strict()
|
|
67
|
+
|
|
68
|
+
export const implementOutputSchemaInternal = z
|
|
69
|
+
.object({
|
|
70
|
+
assumptions: uniqueStringArray('assumption'),
|
|
71
|
+
needsHumanAttention: z.boolean(),
|
|
72
|
+
notes: uniqueStringArray('note'),
|
|
73
|
+
status: z.enum(implementStatusValues),
|
|
74
|
+
summary: nonEmptyStringSchema,
|
|
75
|
+
taskHandle: taskHandleSchema,
|
|
76
|
+
unresolvedItems: uniqueStringArray('unresolved item'),
|
|
77
|
+
})
|
|
78
|
+
.strict()
|
|
79
|
+
|
|
80
|
+
export const reviewOutputSchemaInternal = z
|
|
81
|
+
.object({
|
|
82
|
+
acceptanceChecks: z.array(acceptanceCheckSchema).min(1),
|
|
83
|
+
findings: z.array(reviewFindingSchema),
|
|
84
|
+
overallRisk: z.enum(overallRiskValues),
|
|
85
|
+
summary: nonEmptyStringSchema,
|
|
86
|
+
taskHandle: taskHandleSchema,
|
|
87
|
+
verdict: z.enum(reviewVerdictValues),
|
|
88
|
+
})
|
|
89
|
+
.strict()
|
|
90
|
+
|
|
91
|
+
const taskStateBaseSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
attempt: z.number().int().min(0),
|
|
94
|
+
generation: z.number().int().min(1),
|
|
95
|
+
invalidatedBy: taskHandleSchema.nullable(),
|
|
96
|
+
lastFindings: z.array(reviewFindingSchema),
|
|
97
|
+
lastReviewVerdict: z.enum(reviewVerdictValues).optional(),
|
|
98
|
+
})
|
|
99
|
+
.strict()
|
|
100
|
+
|
|
101
|
+
export const pendingTaskStateSchema = taskStateBaseSchema
|
|
102
|
+
.extend({
|
|
103
|
+
status: z.literal('pending'),
|
|
104
|
+
})
|
|
105
|
+
.strict()
|
|
106
|
+
|
|
107
|
+
export const runningTaskStateSchema = taskStateBaseSchema
|
|
108
|
+
.extend({
|
|
109
|
+
stage: z.enum(runningStageValues),
|
|
110
|
+
status: z.literal('running'),
|
|
111
|
+
})
|
|
112
|
+
.strict()
|
|
113
|
+
|
|
114
|
+
export const reworkTaskStateSchema = taskStateBaseSchema
|
|
115
|
+
.extend({
|
|
116
|
+
status: z.literal('rework'),
|
|
117
|
+
})
|
|
118
|
+
.strict()
|
|
119
|
+
|
|
120
|
+
export const doneTaskStateSchema = taskStateBaseSchema
|
|
121
|
+
.extend({
|
|
122
|
+
commitSha: nonEmptyStringSchema,
|
|
123
|
+
status: z.literal('done'),
|
|
124
|
+
})
|
|
125
|
+
.strict()
|
|
126
|
+
|
|
127
|
+
export const blockedTaskStateSchema = taskStateBaseSchema
|
|
128
|
+
.extend({
|
|
129
|
+
reason: nonEmptyStringSchema,
|
|
130
|
+
status: z.literal('blocked'),
|
|
131
|
+
})
|
|
132
|
+
.strict()
|
|
133
|
+
|
|
134
|
+
export const replanTaskStateSchema = taskStateBaseSchema
|
|
135
|
+
.extend({
|
|
136
|
+
reason: nonEmptyStringSchema,
|
|
137
|
+
status: z.literal('replan'),
|
|
138
|
+
})
|
|
139
|
+
.strict()
|
|
140
|
+
|
|
141
|
+
export const taskStateSchema = z.discriminatedUnion('status', [
|
|
142
|
+
pendingTaskStateSchema,
|
|
143
|
+
runningTaskStateSchema,
|
|
144
|
+
reworkTaskStateSchema,
|
|
145
|
+
doneTaskStateSchema,
|
|
146
|
+
blockedTaskStateSchema,
|
|
147
|
+
replanTaskStateSchema,
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
export const workflowStateSchema = z
|
|
151
|
+
.object({
|
|
152
|
+
currentTaskHandle: taskHandleSchema.nullable(),
|
|
153
|
+
featureId: nonEmptyStringSchema,
|
|
154
|
+
tasks: z.record(taskStateSchema),
|
|
155
|
+
})
|
|
156
|
+
.strict()
|
|
157
|
+
|
|
158
|
+
export const implementArtifactSchema = z
|
|
159
|
+
.object({
|
|
160
|
+
attempt: z.number().int().min(1),
|
|
161
|
+
commitSha: nonEmptyStringSchema.optional(),
|
|
162
|
+
createdAt: dateTimeSchema,
|
|
163
|
+
generation: z.number().int().min(1),
|
|
164
|
+
result: implementOutputSchemaInternal,
|
|
165
|
+
taskHandle: taskHandleSchema,
|
|
166
|
+
})
|
|
167
|
+
.strict()
|
|
168
|
+
|
|
169
|
+
export const reviewArtifactSchema = z
|
|
170
|
+
.object({
|
|
171
|
+
attempt: z.number().int().min(1),
|
|
172
|
+
commitSha: nonEmptyStringSchema.optional(),
|
|
173
|
+
createdAt: dateTimeSchema,
|
|
174
|
+
generation: z.number().int().min(1),
|
|
175
|
+
result: reviewOutputSchemaInternal,
|
|
176
|
+
taskHandle: taskHandleSchema,
|
|
177
|
+
})
|
|
178
|
+
.strict()
|
|
179
|
+
|
|
180
|
+
export const integrateArtifactSchema = z
|
|
181
|
+
.object({
|
|
182
|
+
attempt: z.number().int().min(1),
|
|
183
|
+
createdAt: dateTimeSchema,
|
|
184
|
+
generation: z.number().int().min(1),
|
|
185
|
+
taskHandle: taskHandleSchema,
|
|
186
|
+
result: z
|
|
187
|
+
.object({
|
|
188
|
+
commitSha: nonEmptyStringSchema,
|
|
189
|
+
summary: nonEmptyStringSchema,
|
|
190
|
+
})
|
|
191
|
+
.strict(),
|
|
192
|
+
})
|
|
193
|
+
.strict()
|
|
194
|
+
|
|
195
|
+
export const workflowEventSchema = z
|
|
196
|
+
.object({
|
|
197
|
+
attempt: z.number().int().min(0),
|
|
198
|
+
detail: z.string().optional(),
|
|
199
|
+
generation: z.number().int().min(1),
|
|
200
|
+
taskHandle: taskHandleSchema,
|
|
201
|
+
timestamp: dateTimeSchema,
|
|
202
|
+
type: z.enum(workflowEventTypeValues),
|
|
203
|
+
})
|
|
204
|
+
.strict()
|
|
205
|
+
|
|
206
|
+
export const finalReportTaskSchema = z
|
|
207
|
+
.object({
|
|
208
|
+
attempt: z.number().int().min(0),
|
|
209
|
+
commitSha: nonEmptyStringSchema.optional(),
|
|
210
|
+
generation: z.number().int().min(1),
|
|
211
|
+
lastReviewVerdict: z.enum(reviewVerdictValues).optional(),
|
|
212
|
+
reason: nonEmptyStringSchema.optional(),
|
|
213
|
+
status: z.enum(taskStatusValues),
|
|
214
|
+
taskHandle: taskHandleSchema,
|
|
215
|
+
})
|
|
216
|
+
.strict()
|
|
217
|
+
|
|
218
|
+
export const finalReportSchema = z
|
|
219
|
+
.object({
|
|
220
|
+
featureId: nonEmptyStringSchema,
|
|
221
|
+
generatedAt: dateTimeSchema,
|
|
222
|
+
tasks: z.array(finalReportTaskSchema),
|
|
223
|
+
summary: z
|
|
224
|
+
.object({
|
|
225
|
+
blockedTasks: z.number().int().min(0),
|
|
226
|
+
completedTasks: z.number().int().min(0),
|
|
227
|
+
finalStatus: z.enum(finalStatusValues),
|
|
228
|
+
replanTasks: z.number().int().min(0),
|
|
229
|
+
totalTasks: z.number().int().min(0),
|
|
230
|
+
})
|
|
231
|
+
.strict(),
|
|
232
|
+
})
|
|
233
|
+
.strict()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const nonEmptyStringSchema = z.string().min(1)
|
|
4
|
+
export const taskHandleSchema = nonEmptyStringSchema
|
|
5
|
+
export const dateTimeSchema = z.string().datetime({ offset: true })
|
|
6
|
+
|
|
7
|
+
export const taskStatusValues = [
|
|
8
|
+
'pending',
|
|
9
|
+
'running',
|
|
10
|
+
'rework',
|
|
11
|
+
'done',
|
|
12
|
+
'blocked',
|
|
13
|
+
'replan',
|
|
14
|
+
] as const
|
|
15
|
+
export const runningStageValues = ['implement', 'review', 'integrate'] as const
|
|
16
|
+
export const reviewVerdictValues = [
|
|
17
|
+
'blocked',
|
|
18
|
+
'pass',
|
|
19
|
+
'replan',
|
|
20
|
+
'rework',
|
|
21
|
+
] as const
|
|
22
|
+
export const implementStatusValues = [
|
|
23
|
+
'blocked',
|
|
24
|
+
'implemented',
|
|
25
|
+
'partial',
|
|
26
|
+
] as const
|
|
27
|
+
export const findingSeverityValues = ['high', 'low', 'medium'] as const
|
|
28
|
+
export const acceptanceStatusValues = ['fail', 'pass', 'unclear'] as const
|
|
29
|
+
export const overallRiskValues = ['high', 'low', 'medium'] as const
|
|
30
|
+
export const workflowEventTypeValues = [
|
|
31
|
+
'attempt_started',
|
|
32
|
+
'implement_succeeded',
|
|
33
|
+
'implement_failed',
|
|
34
|
+
'review_started',
|
|
35
|
+
'review_completed',
|
|
36
|
+
'review_failed',
|
|
37
|
+
'integrate_started',
|
|
38
|
+
'integrate_completed',
|
|
39
|
+
'integrate_failed',
|
|
40
|
+
] as const
|
|
41
|
+
export const finalStatusValues = [
|
|
42
|
+
'blocked',
|
|
43
|
+
'completed',
|
|
44
|
+
'in_progress',
|
|
45
|
+
'replan_required',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
function uniqueStrings(items: string[], label: string, ctx: z.RefinementCtx) {
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
for (const [index, item] of items.entries()) {
|
|
51
|
+
if (seen.has(item)) {
|
|
52
|
+
ctx.addIssue({
|
|
53
|
+
code: z.ZodIssueCode.custom,
|
|
54
|
+
message: `Duplicate ${label}: ${item}`,
|
|
55
|
+
path: [index],
|
|
56
|
+
})
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
seen.add(item)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function uniqueStringArray(
|
|
64
|
+
label: string,
|
|
65
|
+
options?: { minItems?: number },
|
|
66
|
+
) {
|
|
67
|
+
const base = z.array(nonEmptyStringSchema)
|
|
68
|
+
const withMin = options?.minItems ? base.min(options.minItems) : base
|
|
69
|
+
return withMin.superRefine((items, ctx) => {
|
|
70
|
+
uniqueStrings(items, label, ctx)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatPath(path: (number | string)[]) {
|
|
75
|
+
if (path.length === 0) {
|
|
76
|
+
return '/'
|
|
77
|
+
}
|
|
78
|
+
return `/${path.join('/')}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatIssues(error: z.ZodError) {
|
|
82
|
+
return error.issues
|
|
83
|
+
.map((issue) => `${formatPath(issue.path)} ${issue.message}`.trim())
|
|
84
|
+
.join('; ')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseWithSchema<T>(schema: z.ZodType<T>, value: unknown): T {
|
|
88
|
+
const result = schema.safeParse(value)
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
throw new Error(formatIssues(result.error))
|
|
91
|
+
}
|
|
92
|
+
return result.data
|
|
93
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
|
|
3
|
+
export interface OpenSpecCliTask {
|
|
4
|
+
description: string
|
|
5
|
+
id: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OpenSpecApplyInstructions {
|
|
9
|
+
changeName: string
|
|
10
|
+
contextFiles: Record<string, string>
|
|
11
|
+
instruction: string
|
|
12
|
+
progress: {
|
|
13
|
+
complete: number
|
|
14
|
+
total: number
|
|
15
|
+
}
|
|
16
|
+
schemaName: string
|
|
17
|
+
state: string
|
|
18
|
+
tasks: OpenSpecCliTask[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenSpecStatus {
|
|
22
|
+
applyRequires: string[]
|
|
23
|
+
artifacts: {
|
|
24
|
+
id: string
|
|
25
|
+
outputPath: string
|
|
26
|
+
status: string
|
|
27
|
+
}[]
|
|
28
|
+
changeName: string
|
|
29
|
+
isComplete: boolean
|
|
30
|
+
schemaName: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function parseCliJson<T>(stdout: string): Promise<T> {
|
|
34
|
+
const start = stdout.indexOf('{')
|
|
35
|
+
if (start === -1) {
|
|
36
|
+
throw new Error('OpenSpec CLI did not return JSON payload')
|
|
37
|
+
}
|
|
38
|
+
return JSON.parse(stdout.slice(start)) as T
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readOpenSpecApplyInstructions(input: {
|
|
42
|
+
changeName: string
|
|
43
|
+
workspaceRoot: string
|
|
44
|
+
}): Promise<OpenSpecApplyInstructions> {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execa(
|
|
47
|
+
'openspec',
|
|
48
|
+
['instructions', 'apply', '--change', input.changeName, '--json'],
|
|
49
|
+
{
|
|
50
|
+
cwd: input.workspaceRoot,
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
return await parseCliJson<OpenSpecApplyInstructions>(stdout)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Failed to read OpenSpec apply instructions for ${input.changeName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readOpenSpecStatus(input: {
|
|
62
|
+
changeName: string
|
|
63
|
+
workspaceRoot: string
|
|
64
|
+
}): Promise<OpenSpecStatus> {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execa(
|
|
67
|
+
'openspec',
|
|
68
|
+
['status', '--change', input.changeName, '--json'],
|
|
69
|
+
{
|
|
70
|
+
cwd: input.workspaceRoot,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
return await parseCliJson<OpenSpecStatus>(stdout)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Failed to read OpenSpec status for ${input.changeName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
function normalizeGlobPath(value: string) {
|
|
5
|
+
return value.replaceAll(path.sep, '/')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function escapeRegex(value: string) {
|
|
9
|
+
return value.replace(/[.+^${}()|[\]\\]/g, String.raw`\$&`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function globToRegExp(pattern: string) {
|
|
13
|
+
const normalized = normalizeGlobPath(pattern)
|
|
14
|
+
let source = ''
|
|
15
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
16
|
+
const char = normalized[index] ?? ''
|
|
17
|
+
const next = normalized[index + 1]
|
|
18
|
+
const nextNext = normalized[index + 2]
|
|
19
|
+
if (char === '*' && next === '*' && nextNext === '/') {
|
|
20
|
+
source += '(?:[^/]+/)*'
|
|
21
|
+
index += 2
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (char === '*' && next === '*') {
|
|
25
|
+
source += '.*'
|
|
26
|
+
index += 1
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (char === '*') {
|
|
30
|
+
source += '[^/]*'
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
source += escapeRegex(char)
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`^${source}$`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listFilesRecursively(root: string): Promise<string[]> {
|
|
39
|
+
const entries = await readdir(root, { withFileTypes: true })
|
|
40
|
+
const files = await Promise.all(
|
|
41
|
+
entries.map(async (entry) => {
|
|
42
|
+
const entryPath = path.join(root, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
return listFilesRecursively(entryPath)
|
|
45
|
+
}
|
|
46
|
+
return [entryPath]
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
return files.flat()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function expandPattern(
|
|
53
|
+
baseDir: string,
|
|
54
|
+
pattern: string,
|
|
55
|
+
): Promise<string[]> {
|
|
56
|
+
const isAbsolutePattern = path.isAbsolute(pattern)
|
|
57
|
+
const matchRoot = isAbsolutePattern ? path.parse(pattern).root : baseDir
|
|
58
|
+
|
|
59
|
+
if (!pattern.includes('*')) {
|
|
60
|
+
return [isAbsolutePattern ? pattern : path.join(baseDir, pattern)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const normalizedPattern = normalizeGlobPath(
|
|
64
|
+
isAbsolutePattern ? path.relative(matchRoot, pattern) : pattern,
|
|
65
|
+
)
|
|
66
|
+
const firstGlobIndex = normalizedPattern.search(/\*/)
|
|
67
|
+
const slashIndex =
|
|
68
|
+
firstGlobIndex === -1
|
|
69
|
+
? normalizedPattern.length
|
|
70
|
+
: normalizedPattern.lastIndexOf('/', firstGlobIndex)
|
|
71
|
+
const searchPrefix =
|
|
72
|
+
slashIndex <= 0 ? '.' : normalizedPattern.slice(0, slashIndex)
|
|
73
|
+
const searchRoot = path.join(matchRoot, searchPrefix)
|
|
74
|
+
const regex = globToRegExp(normalizedPattern)
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const files = await listFilesRecursively(searchRoot)
|
|
78
|
+
return files
|
|
79
|
+
.filter((filePath) =>
|
|
80
|
+
regex.test(normalizeGlobPath(path.relative(matchRoot, filePath))),
|
|
81
|
+
)
|
|
82
|
+
.sort((left, right) =>
|
|
83
|
+
normalizeGlobPath(path.relative(matchRoot, left)).localeCompare(
|
|
84
|
+
normalizeGlobPath(path.relative(matchRoot, right)),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
} catch {
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readPattern(baseDir: string, pattern: string) {
|
|
93
|
+
const filePaths = await expandPattern(baseDir, pattern)
|
|
94
|
+
const contents = await Promise.all(
|
|
95
|
+
filePaths.map((filePath) => readFile(filePath, 'utf8')),
|
|
96
|
+
)
|
|
97
|
+
return contents.join('\n\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function readContextFileMap(
|
|
101
|
+
baseDir: string,
|
|
102
|
+
contextFiles: Record<string, string>,
|
|
103
|
+
): Promise<Map<string, string>> {
|
|
104
|
+
const orderedKeys = ['proposal', 'design', 'specs', 'tasks']
|
|
105
|
+
const remainingKeys = Object.keys(contextFiles).filter(
|
|
106
|
+
(key) => !orderedKeys.includes(key),
|
|
107
|
+
)
|
|
108
|
+
const finalKeys = [
|
|
109
|
+
...orderedKeys.filter((key) => key in contextFiles),
|
|
110
|
+
...remainingKeys,
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const entries = await Promise.all(
|
|
114
|
+
finalKeys.map(
|
|
115
|
+
async (key) =>
|
|
116
|
+
[key, await readPattern(baseDir, contextFiles[key]!)] as const,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return new Map(entries)
|
|
121
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
export interface OpenSpecTask {
|
|
4
|
+
checked: boolean
|
|
5
|
+
handle: string
|
|
6
|
+
ordinal: number
|
|
7
|
+
rawLine: string
|
|
8
|
+
sectionTitle: string
|
|
9
|
+
title: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createTask(
|
|
13
|
+
line: string,
|
|
14
|
+
ordinal: number,
|
|
15
|
+
sectionTitle: string,
|
|
16
|
+
): OpenSpecTask {
|
|
17
|
+
const checkboxMatch = line.match(/^[-*]\s+\[([ x])\]\s+(\S.*)$/i)
|
|
18
|
+
if (!checkboxMatch) {
|
|
19
|
+
throw new Error(`Invalid task line: ${line}`)
|
|
20
|
+
}
|
|
21
|
+
const body = checkboxMatch[2]!
|
|
22
|
+
const numberedMatch = body.match(/^(\d+(?:\.\d+)*)\s+(\S.*)$/)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
checked: checkboxMatch[1]!.toLowerCase() === 'x',
|
|
26
|
+
handle: numberedMatch?.[1] ?? `task-${ordinal}`,
|
|
27
|
+
ordinal,
|
|
28
|
+
rawLine: line,
|
|
29
|
+
sectionTitle,
|
|
30
|
+
title: numberedMatch?.[2] ?? body,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function parseTasksMd(tasksPath: string): Promise<OpenSpecTask[]> {
|
|
35
|
+
const content = await readFile(tasksPath, 'utf8')
|
|
36
|
+
const lines = content.split(/\r?\n/)
|
|
37
|
+
const tasks: OpenSpecTask[] = []
|
|
38
|
+
let currentSectionTitle = 'unknown'
|
|
39
|
+
|
|
40
|
+
for (const rawLine of lines) {
|
|
41
|
+
const line = rawLine.trimEnd()
|
|
42
|
+
if (line.startsWith('## ')) {
|
|
43
|
+
currentSectionTitle = line.replace(/^##\s+/, '').trim()
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (!line.match(/^[-*]\s+\[[ x]\]\s+/i)) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
tasks.push(createTask(line, tasks.length + 1, currentSectionTitle))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (tasks.length === 0) {
|
|
53
|
+
throw new Error('No tasks found in tasks.md')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return tasks
|
|
57
|
+
}
|