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,235 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { readOpenSpecApplyInstructions } from './cli-json'
5
+ import { readContextFileMap } from './context-files'
6
+
7
+ import type {
8
+ OpenTaskSourceInput,
9
+ TaskPrompt,
10
+ TaskSourceSession,
11
+ } from '../types'
12
+ import type { OpenSpecTask } from './parse-tasks-md'
13
+
14
+ function createImplementPrompt(
15
+ task: OpenSpecTask,
16
+ input: {
17
+ apply: Awaited<ReturnType<typeof readOpenSpecApplyInstructions>>
18
+ context: Map<string, string>
19
+ featureId: string
20
+ },
21
+ ): TaskPrompt {
22
+ return {
23
+ instructions: [
24
+ 'Implement only the current task from the OpenSpec change.',
25
+ 'Use the OpenSpec apply instruction as the execution contract.',
26
+ input.apply.instruction,
27
+ 'Do not mark tasks.md complete; while will apply completion after review/integrate.',
28
+ 'Do not move to the next task.',
29
+ 'Return structured output only.',
30
+ ],
31
+ sections: [
32
+ { content: input.featureId, title: 'Change' },
33
+ { content: input.apply.schemaName, title: 'Schema' },
34
+ { content: input.apply.state, title: 'Apply State' },
35
+ {
36
+ content: `${input.apply.progress.complete}/${input.apply.progress.total} complete`,
37
+ title: 'Apply Progress',
38
+ },
39
+ { content: task.rawLine, title: 'Current Task' },
40
+ { content: task.sectionTitle, title: 'Task Group' },
41
+ { content: input.context.get('proposal') ?? '', title: 'Proposal' },
42
+ { content: input.context.get('design') ?? '', title: 'Design' },
43
+ { content: input.context.get('specs') ?? '', title: 'Specs' },
44
+ { content: input.context.get('tasks') ?? '', title: 'Tasks' },
45
+ ],
46
+ }
47
+ }
48
+
49
+ function createReviewPrompt(
50
+ task: OpenSpecTask,
51
+ input: {
52
+ apply: Awaited<ReturnType<typeof readOpenSpecApplyInstructions>>
53
+ context: Map<string, string>
54
+ featureId: string
55
+ },
56
+ ): TaskPrompt {
57
+ return {
58
+ instructions: [
59
+ 'Review only the current task from the OpenSpec change.',
60
+ 'Use the OpenSpec apply instruction as the execution contract.',
61
+ input.apply.instruction,
62
+ 'Judge the implementation against the current task, OpenSpec context, and actual changed files.',
63
+ 'Only return verdict "pass" when the current task is satisfied by the implementation.',
64
+ 'Do not expand the review to unrelated repository changes.',
65
+ ],
66
+ sections: [
67
+ { content: input.featureId, title: 'Change' },
68
+ { content: input.apply.schemaName, title: 'Schema' },
69
+ { content: input.apply.state, title: 'Apply State' },
70
+ {
71
+ content: `${input.apply.progress.complete}/${input.apply.progress.total} complete`,
72
+ title: 'Apply Progress',
73
+ },
74
+ { content: task.rawLine, title: 'Current Task' },
75
+ { content: task.sectionTitle, title: 'Task Group' },
76
+ { content: input.context.get('proposal') ?? '', title: 'Proposal' },
77
+ { content: input.context.get('design') ?? '', title: 'Design' },
78
+ { content: input.context.get('specs') ?? '', title: 'Specs' },
79
+ { content: input.context.get('tasks') ?? '', title: 'Tasks' },
80
+ ],
81
+ }
82
+ }
83
+
84
+ function splitTasksMd(tasksMd: string) {
85
+ return {
86
+ lineEnding: tasksMd.includes('\r\n') ? '\r\n' : '\n',
87
+ lines: tasksMd.split(/\r?\n/),
88
+ }
89
+ }
90
+
91
+ function getTaskLineIndex(lines: string[], task: OpenSpecTask) {
92
+ let currentOrdinal = 0
93
+ for (const [index, line] of lines.entries()) {
94
+ if (!line.match(/^[-*]\s+\[[ x]\]\s+/i)) {
95
+ continue
96
+ }
97
+ currentOrdinal += 1
98
+ if (currentOrdinal === task.ordinal) {
99
+ return index
100
+ }
101
+ }
102
+ throw new Error(`Task line not found for handle: ${task.handle}`)
103
+ }
104
+
105
+ function isTaskChecked(tasksMd: string, task: OpenSpecTask) {
106
+ const { lines } = splitTasksMd(tasksMd)
107
+ const line = lines[getTaskLineIndex(lines, task)]
108
+ if (!line) {
109
+ return false
110
+ }
111
+ return /^[-*]\s+\[x\]\s+/i.test(line)
112
+ }
113
+
114
+ async function updateTaskCheckbox(
115
+ tasksPath: string,
116
+ task: OpenSpecTask,
117
+ checked: boolean,
118
+ ) {
119
+ const content = await readFile(tasksPath, 'utf8')
120
+ const { lineEnding, lines } = splitTasksMd(content)
121
+ const targetIndex = getTaskLineIndex(lines, task)
122
+ const targetLine = lines[targetIndex]
123
+ if (!targetLine) {
124
+ throw new Error(`Task line not found for handle: ${task.handle}`)
125
+ }
126
+ const pattern = /^([-*]\s+)\[[ x]\](\s+(?:\S.*)?)$/i
127
+ const match = targetLine.match(pattern)
128
+ if (!match) {
129
+ throw new Error(`Invalid task line: ${targetLine}`)
130
+ }
131
+ const replacementCheckbox = checked ? '[X]' : '[ ]'
132
+ lines[targetIndex] = `${match[1]}${replacementCheckbox}${match[2]}`
133
+ await writeFile(tasksPath, lines.join(lineEnding))
134
+ }
135
+
136
+ export interface CreateOpenSpecSessionInput extends OpenTaskSourceInput {
137
+ tasks: OpenSpecTask[]
138
+ }
139
+
140
+ export function createOpenSpecSession(
141
+ input: CreateOpenSpecSessionInput,
142
+ ): TaskSourceSession {
143
+ const tasksPath = path.join(input.featureDir, 'tasks.md')
144
+ const tasksByHandle = new Map(input.tasks.map((task) => [task.handle, task]))
145
+
146
+ const getTask = (taskHandle: string) => {
147
+ const task = tasksByHandle.get(taskHandle)
148
+ if (!task) {
149
+ throw new Error(`Unknown task selector: ${taskHandle}`)
150
+ }
151
+ return task
152
+ }
153
+
154
+ const loadApplyContext = async () => {
155
+ const apply = await readOpenSpecApplyInstructions({
156
+ changeName: input.featureId,
157
+ workspaceRoot: input.workspaceRoot,
158
+ })
159
+ const context = await readContextFileMap(
160
+ input.featureDir,
161
+ apply.contextFiles,
162
+ )
163
+ return {
164
+ apply,
165
+ context,
166
+ }
167
+ }
168
+
169
+ return {
170
+ async applyTaskCompletion(taskHandle: string) {
171
+ const task = getTask(taskHandle)
172
+ if (await this.isTaskCompleted(taskHandle)) {
173
+ return
174
+ }
175
+ await updateTaskCheckbox(tasksPath, task, true)
176
+ },
177
+ buildCommitSubject(taskHandle: string) {
178
+ const task = getTask(taskHandle)
179
+ return `Task ${input.featureId}/${task.handle}: ${task.title}`
180
+ },
181
+ async buildImplementPrompt(args) {
182
+ const task = getTask(args.taskHandle)
183
+ const applyContext = await loadApplyContext()
184
+ return createImplementPrompt(task, {
185
+ ...applyContext,
186
+ featureId: input.featureId,
187
+ })
188
+ },
189
+ async buildReviewPrompt(args) {
190
+ const task = getTask(args.taskHandle)
191
+ const applyContext = await loadApplyContext()
192
+ void args
193
+ return createReviewPrompt(task, {
194
+ ...applyContext,
195
+ featureId: input.featureId,
196
+ })
197
+ },
198
+ async getCompletionCriteria(taskHandle: string) {
199
+ return [getTask(taskHandle).title]
200
+ },
201
+ getTaskDependencies(taskHandle: string) {
202
+ void taskHandle
203
+ return []
204
+ },
205
+ async isTaskCompleted(taskHandle: string) {
206
+ const task = getTask(taskHandle)
207
+ const tasksMd = await readFile(tasksPath, 'utf8')
208
+ return isTaskChecked(tasksMd, task)
209
+ },
210
+ listTasks() {
211
+ return input.tasks.map((task) => task.handle)
212
+ },
213
+ resolveTaskSelector(selector: string) {
214
+ if (tasksByHandle.has(selector)) {
215
+ return selector
216
+ }
217
+ if (!selector.match(/^\d+$/)) {
218
+ throw new Error(`Unknown task selector: ${selector}`)
219
+ }
220
+ const ordinal = Number(selector)
221
+ const task = input.tasks.find((item) => item.ordinal === ordinal)
222
+ if (!task) {
223
+ throw new Error(`Unknown task selector: ${selector}`)
224
+ }
225
+ return task.handle
226
+ },
227
+ async revertTaskCompletion(taskHandle: string) {
228
+ const task = getTask(taskHandle)
229
+ if (!(await this.isTaskCompleted(taskHandle))) {
230
+ return
231
+ }
232
+ await updateTaskCheckbox(tasksPath, task, false)
233
+ },
234
+ }
235
+ }
@@ -0,0 +1,59 @@
1
+ import path from 'node:path'
2
+
3
+ import * as fsExtra from 'fs-extra'
4
+
5
+ import { readOpenSpecStatus } from './cli-json'
6
+ import { readContextFileMap } from './context-files'
7
+ import { parseTasksMd } from './parse-tasks-md'
8
+ import { createOpenSpecSession } from './session'
9
+
10
+ import type { OpenTaskSourceInput, TaskSource } from '../types'
11
+
12
+ async function assertRequiredChangeFiles(featureDir: string) {
13
+ for (const fileName of ['proposal.md', 'design.md', 'tasks.md']) {
14
+ const filePath = path.join(featureDir, fileName)
15
+ const exists = await fsExtra.pathExists(filePath)
16
+ if (!exists) {
17
+ throw new Error(`Missing required change file: ${filePath}`)
18
+ }
19
+ }
20
+
21
+ const context = await readContextFileMap(featureDir, {
22
+ specs: 'specs/**/*.md',
23
+ })
24
+ if (!(context.get('specs') ?? '').trim()) {
25
+ throw new Error(`Missing required change specs under: ${featureDir}/specs`)
26
+ }
27
+ }
28
+
29
+ async function assertOpenSpecReady(input: OpenTaskSourceInput) {
30
+ const status = await readOpenSpecStatus({
31
+ changeName: input.featureId,
32
+ workspaceRoot: input.workspaceRoot,
33
+ })
34
+
35
+ const allApplyRequirementsReady = status.applyRequires.every((artifactId) => {
36
+ const artifact = status.artifacts.find((item) => item.id === artifactId)
37
+ return artifact?.status === 'done'
38
+ })
39
+
40
+ if (allApplyRequirementsReady) {
41
+ return
42
+ }
43
+ throw new Error(
44
+ `OpenSpec change is not ready for apply instructions: ${input.featureId}`,
45
+ )
46
+ }
47
+
48
+ export const openspecTaskSource: TaskSource = {
49
+ name: 'openspec',
50
+ async open(input: OpenTaskSourceInput) {
51
+ await assertRequiredChangeFiles(input.featureDir)
52
+ await assertOpenSpecReady(input)
53
+ const tasks = await parseTasksMd(path.join(input.featureDir, 'tasks.md'))
54
+ return createOpenSpecSession({
55
+ ...input,
56
+ tasks,
57
+ })
58
+ },
59
+ }
@@ -0,0 +1,22 @@
1
+ import { openspecTaskSource } from './openspec/source'
2
+ import { specKitTaskSource } from './spec-kit/source'
3
+
4
+ import type { TaskSource } from './types'
5
+
6
+ export function getTaskSource(name: string): TaskSource {
7
+ if (name === 'spec-kit') {
8
+ return specKitTaskSource
9
+ }
10
+ if (name === 'openspec') {
11
+ return openspecTaskSource
12
+ }
13
+
14
+ throw new Error(`Unknown task source: ${name}`)
15
+ }
16
+
17
+ export async function openTaskSource(
18
+ name: string,
19
+ input: Parameters<TaskSource['open']>[0],
20
+ ) {
21
+ return getTaskSource(name).open(input)
22
+ }
@@ -0,0 +1,48 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ export interface SpecKitTask {
4
+ id: string
5
+ phase: string
6
+ rawLine: string
7
+ title: string
8
+ }
9
+
10
+ function createTask(line: string, phase: string): SpecKitTask {
11
+ const match = line.match(
12
+ /^- \[[ xX]\] (T\d{3,})(?: \[P\])?(?: \[[A-Z]{2,}\d+\])? (.+)$/,
13
+ )
14
+ if (!match) {
15
+ throw new Error(`Invalid task line: ${line}`)
16
+ }
17
+ return {
18
+ id: match[1]!,
19
+ phase,
20
+ rawLine: line,
21
+ title: match[2]!,
22
+ }
23
+ }
24
+
25
+ export async function parseTasksMd(tasksPath: string): Promise<SpecKitTask[]> {
26
+ const content = await readFile(tasksPath, 'utf8')
27
+ const lines = content.split(/\r?\n/)
28
+ const tasks: SpecKitTask[] = []
29
+ let currentPhase = 'unknown'
30
+
31
+ for (const rawLine of lines) {
32
+ const line = rawLine.trimEnd()
33
+ if (line.startsWith('## ')) {
34
+ currentPhase = line.replace(/^##\s+/, '').trim()
35
+ continue
36
+ }
37
+
38
+ if (line.startsWith('- [') && line.includes(' T')) {
39
+ tasks.push(createTask(line, currentPhase))
40
+ }
41
+ }
42
+
43
+ if (tasks.length === 0) {
44
+ throw new Error('No tasks found in tasks.md')
45
+ }
46
+
47
+ return tasks
48
+ }
@@ -0,0 +1,174 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import * as fsExtra from 'fs-extra'
5
+
6
+ import type {
7
+ OpenTaskSourceInput,
8
+ TaskPrompt,
9
+ TaskSourceSession,
10
+ } from '../types'
11
+ import type { SpecKitTask } from './parse-tasks-md'
12
+
13
+ async function readRequiredTextFile(filePath: string) {
14
+ const exists = await fsExtra.pathExists(filePath)
15
+ if (!exists) {
16
+ throw new Error(`Missing required feature file: ${filePath}`)
17
+ }
18
+ return readFile(filePath, 'utf8')
19
+ }
20
+
21
+ function isTaskChecked(tasksMd: string, taskId: string) {
22
+ const pattern = new RegExp(String.raw`^- \[[Xx]\] ${taskId}(?=\b)`, 'm')
23
+ return pattern.test(tasksMd)
24
+ }
25
+
26
+ async function updateTaskCheckbox(
27
+ tasksPath: string,
28
+ taskHandle: string,
29
+ checked: boolean,
30
+ ) {
31
+ const content = await readFile(tasksPath, 'utf8')
32
+ const pattern = new RegExp(String.raw`^- \[[ Xx]\] ${taskHandle}(?=\b)`, 'm')
33
+ const replacement = checked ? `- [X] ${taskHandle}` : `- [ ] ${taskHandle}`
34
+ await writeFile(tasksPath, content.replace(pattern, replacement))
35
+ }
36
+
37
+ function createImplementPrompt(
38
+ task: SpecKitTask,
39
+ input: {
40
+ plan: string
41
+ spec: string
42
+ tasks: string
43
+ },
44
+ ): TaskPrompt {
45
+ return {
46
+ instructions: [
47
+ 'Implement only the current task.',
48
+ 'Use the provided source documents as the source of truth.',
49
+ 'Modify only the files that are reasonably required for the current task.',
50
+ 'Do not modify tasks.md.',
51
+ 'Do not move to the next task.',
52
+ 'Do not declare the task finalized.',
53
+ 'Return structured output only.',
54
+ ],
55
+ sections: [
56
+ { content: task.rawLine, title: 'Task' },
57
+ { content: task.phase, title: 'Phase' },
58
+ { content: input.spec, title: 'Spec' },
59
+ { content: input.plan, title: 'Plan' },
60
+ { content: input.tasks, title: 'Tasks' },
61
+ ],
62
+ }
63
+ }
64
+
65
+ function createReviewPrompt(
66
+ task: SpecKitTask,
67
+ input: {
68
+ plan: string
69
+ spec: string
70
+ tasks: string
71
+ },
72
+ ): TaskPrompt {
73
+ return {
74
+ instructions: [
75
+ 'Review only the current task.',
76
+ 'Use the provided source documents to judge whether the task matches the intended implementation.',
77
+ 'Evaluate the task description, source documents, actual changed files, and overall risk.',
78
+ 'Only return verdict "pass" when the current task is satisfied by the implementation.',
79
+ 'acceptanceChecks must stay consistent with the current task description.',
80
+ 'Do not expand the review to unrelated files or repository-wide history.',
81
+ ],
82
+ sections: [
83
+ { content: task.rawLine, title: 'Task' },
84
+ { content: task.phase, title: 'Phase' },
85
+ { content: input.spec, title: 'Spec' },
86
+ { content: input.plan, title: 'Plan' },
87
+ { content: input.tasks, title: 'Tasks' },
88
+ ],
89
+ }
90
+ }
91
+
92
+ export interface CreateSpecKitSessionInput extends OpenTaskSourceInput {
93
+ tasks: SpecKitTask[]
94
+ }
95
+
96
+ export function createSpecKitSession(
97
+ input: CreateSpecKitSessionInput,
98
+ ): TaskSourceSession {
99
+ const tasksPath = path.join(input.featureDir, 'tasks.md')
100
+ const planPath = path.join(input.featureDir, 'plan.md')
101
+ const specPath = path.join(input.featureDir, 'spec.md')
102
+ const tasksById = new Map(input.tasks.map((task) => [task.id, task]))
103
+
104
+ const getTask = (taskHandle: string) => {
105
+ const task = tasksById.get(taskHandle)
106
+ if (!task) {
107
+ throw new Error(`Unknown task selector: ${taskHandle}`)
108
+ }
109
+ return task
110
+ }
111
+
112
+ const loadSharedDocs = async () => {
113
+ const [plan, spec, tasks] = await Promise.all([
114
+ readRequiredTextFile(planPath),
115
+ readRequiredTextFile(specPath),
116
+ readRequiredTextFile(tasksPath),
117
+ ])
118
+ return {
119
+ plan,
120
+ spec,
121
+ tasks,
122
+ }
123
+ }
124
+
125
+ return {
126
+ async applyTaskCompletion(taskHandle: string) {
127
+ if (await this.isTaskCompleted(taskHandle)) {
128
+ return
129
+ }
130
+ await updateTaskCheckbox(tasksPath, taskHandle, true)
131
+ },
132
+ buildCommitSubject(taskHandle: string) {
133
+ const task = getTask(taskHandle)
134
+ return `Task ${task.id}: ${task.title}`
135
+ },
136
+ async buildImplementPrompt(args) {
137
+ const task = getTask(args.taskHandle)
138
+ const docs = await loadSharedDocs()
139
+ return createImplementPrompt(task, docs)
140
+ },
141
+ async buildReviewPrompt(args) {
142
+ const task = getTask(args.taskHandle)
143
+ const docs = await loadSharedDocs()
144
+ void args
145
+ return createReviewPrompt(task, docs)
146
+ },
147
+ async getCompletionCriteria(taskHandle: string) {
148
+ return [getTask(taskHandle).title]
149
+ },
150
+ getTaskDependencies(taskHandle: string) {
151
+ void taskHandle
152
+ return []
153
+ },
154
+ async isTaskCompleted(taskHandle: string) {
155
+ const tasksMd = await readRequiredTextFile(tasksPath)
156
+ return isTaskChecked(tasksMd, taskHandle)
157
+ },
158
+ listTasks() {
159
+ return input.tasks.map((task) => task.id)
160
+ },
161
+ resolveTaskSelector(selector: string) {
162
+ if (!tasksById.has(selector)) {
163
+ throw new Error(`Unknown task selector: ${selector}`)
164
+ }
165
+ return selector
166
+ },
167
+ async revertTaskCompletion(taskHandle: string) {
168
+ if (!(await this.isTaskCompleted(taskHandle))) {
169
+ return
170
+ }
171
+ await updateTaskCheckbox(tasksPath, taskHandle, false)
172
+ },
173
+ }
174
+ }
@@ -0,0 +1,30 @@
1
+ import path from 'node:path'
2
+
3
+ import * as fsExtra from 'fs-extra'
4
+
5
+ import { parseTasksMd } from './parse-tasks-md'
6
+ import { createSpecKitSession } from './session'
7
+
8
+ import type { OpenTaskSourceInput, TaskSource } from '../types'
9
+
10
+ async function assertRequiredFeatureFiles(featureDir: string) {
11
+ for (const fileName of ['spec.md', 'plan.md', 'tasks.md']) {
12
+ const filePath = path.join(featureDir, fileName)
13
+ const fileExists = await fsExtra.pathExists(filePath)
14
+ if (!fileExists) {
15
+ throw new Error(`Missing required feature file: ${filePath}`)
16
+ }
17
+ }
18
+ }
19
+
20
+ export const specKitTaskSource: TaskSource = {
21
+ name: 'spec-kit',
22
+ async open(input: OpenTaskSourceInput) {
23
+ await assertRequiredFeatureFiles(input.featureDir)
24
+ const tasks = await parseTasksMd(path.join(input.featureDir, 'tasks.md'))
25
+ return createSpecKitSession({
26
+ ...input,
27
+ tasks,
28
+ })
29
+ },
30
+ }
@@ -0,0 +1,47 @@
1
+ import type { ImplementOutput, ReviewFinding } from '../types'
2
+
3
+ export type TaskSourceName = string
4
+
5
+ export interface TaskPrompt {
6
+ instructions: string[]
7
+ sections: {
8
+ content: string
9
+ title: string
10
+ }[]
11
+ }
12
+
13
+ export interface TaskSourceSession {
14
+ applyTaskCompletion: (taskHandle: string) => Promise<void>
15
+ buildCommitSubject: (taskHandle: string) => string
16
+ buildImplementPrompt: (input: {
17
+ attempt: number
18
+ generation: number
19
+ lastFindings: ReviewFinding[]
20
+ taskHandle: string
21
+ }) => Promise<TaskPrompt>
22
+ buildReviewPrompt: (input: {
23
+ actualChangedFiles: string[]
24
+ attempt: number
25
+ generation: number
26
+ implement: ImplementOutput
27
+ lastFindings: ReviewFinding[]
28
+ taskHandle: string
29
+ }) => Promise<TaskPrompt>
30
+ getCompletionCriteria: (taskHandle: string) => Promise<string[]>
31
+ getTaskDependencies: (taskHandle: string) => string[]
32
+ isTaskCompleted: (taskHandle: string) => Promise<boolean>
33
+ listTasks: () => string[]
34
+ resolveTaskSelector: (selector: string) => string
35
+ revertTaskCompletion: (taskHandle: string) => Promise<void>
36
+ }
37
+
38
+ export interface OpenTaskSourceInput {
39
+ featureDir: string
40
+ featureId: string
41
+ workspaceRoot: string
42
+ }
43
+
44
+ export interface TaskSource {
45
+ readonly name: TaskSourceName
46
+ open: (input: OpenTaskSourceInput) => Promise<TaskSourceSession>
47
+ }
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type {
2
+ AcceptanceCheck,
3
+ FinalReport,
4
+ FinalReportTask,
5
+ ImplementArtifact,
6
+ ImplementOutput,
7
+ IntegrateArtifact,
8
+ PendingTaskState,
9
+ ReviewArtifact,
10
+ ReviewFinding,
11
+ ReviewOutput,
12
+ ReviewVerdict,
13
+ RunningStage,
14
+ RunningTaskState,
15
+ TaskGraph,
16
+ TaskState,
17
+ TaskStatus,
18
+ TaskTopologyEntry,
19
+ WorkflowEvent,
20
+ WorkflowEventType,
21
+ WorkflowState,
22
+ } from './schema/index'
23
+
24
+ export interface WorkspaceContext {
25
+ featureDir: string
26
+ featureId: string
27
+ runtimeDir: string
28
+ workspaceRoot: string
29
+ }
@@ -0,0 +1,31 @@
1
+ import path from 'node:path'
2
+
3
+ import * as fsExtra from 'fs-extra'
4
+
5
+ export function isWithinRelativePath(targetPath: string, basePath: string) {
6
+ return targetPath === basePath || targetPath.startsWith(`${basePath}/`)
7
+ }
8
+
9
+ export function parsePorcelainPath(line: string) {
10
+ const rawPath = line.slice(3).trim()
11
+ const renamedSegments = rawPath.split(' -> ')
12
+ return renamedSegments.at(-1) ?? rawPath
13
+ }
14
+
15
+ export function filterPorcelainStatus(
16
+ lines: string[],
17
+ ignoredBasePath: string,
18
+ ) {
19
+ return lines.filter((line) => {
20
+ const filePath = parsePorcelainPath(line)
21
+ return !isWithinRelativePath(filePath, ignoredBasePath)
22
+ })
23
+ }
24
+
25
+ export async function writeJsonAtomic(filePath: string, value: unknown) {
26
+ const dir = path.dirname(filePath)
27
+ await fsExtra.ensureDir(dir)
28
+ const tempFile = `${filePath}.tmp`
29
+ await fsExtra.writeFile(tempFile, JSON.stringify(value, null, 2))
30
+ await fsExtra.rename(tempFile, filePath)
31
+ }