nightralph 0.0.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.
- package/.claude/settings.local.json +5 -0
- package/AGENTS.md +60 -0
- package/CLAUDE.md +1 -0
- package/PROMPT.md +95 -0
- package/README.md +138 -0
- package/WIP.md +5 -0
- package/dist/docs-templates/domain.md +36 -0
- package/dist/docs-templates/issue-tracker-github.md +228 -0
- package/dist/docs-templates/issue-tracker.md +30 -0
- package/dist/docs-templates/triage-labels.md +15 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +7 -0
- package/dist/meta.json +207 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +7 -0
- package/dist/resolve.js +71 -0
- package/dist/resolve.js.map +7 -0
- package/dist/setup.js +151 -0
- package/dist/setup.js.map +7 -0
- package/dist/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/dist/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/dist/skills/domain-modeling/SKILL.md +74 -0
- package/dist/skills/domain-modeling/agents/openai.yaml +3 -0
- package/dist/skills/grill/SKILL.md +7 -0
- package/dist/skills/grill/agents/openai.yaml +5 -0
- package/dist/skills/grilling/SKILL.md +28 -0
- package/dist/skills/grilling/agents/openai.yaml +3 -0
- package/dist/skills/tdd/SKILL.md +38 -0
- package/dist/skills/tdd/agents/openai.yaml +3 -0
- package/dist/skills/tdd/mocking.md +59 -0
- package/dist/skills/tdd/tests.md +77 -0
- package/dist/skills/to-spec/SKILL.md +75 -0
- package/dist/skills/to-spec/agents/openai.yaml +5 -0
- package/dist/skills/to-tickets/SKILL.md +105 -0
- package/dist/skills/to-tickets/agents/openai.yaml +5 -0
- package/dist/skills/upstream.json +5 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/orchestrator.d.ts +31 -0
- package/dist/src/orchestrator.d.ts.map +1 -0
- package/dist/src/resolve.d.ts +10 -0
- package/dist/src/resolve.d.ts.map +1 -0
- package/dist/src/setup.d.ts +13 -0
- package/dist/src/setup.d.ts.map +1 -0
- package/dist/test/integration.test.js +848 -0
- package/dist/test/integration.test.js.map +7 -0
- package/dist/test/orchestrator.test.js +749 -0
- package/dist/test/orchestrator.test.js.map +7 -0
- package/dist/test/setup.test.js +574 -0
- package/dist/test/setup.test.js.map +7 -0
- package/docs/README.md +12 -0
- package/docs/agents/domain.md +36 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase-2-future-work.md +144 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_01.md +292 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_02.md +212 -0
- package/docs/implementation-plans/2026-08-27-issue-tracker-choice/test-requirements.md +203 -0
- package/docs/skills.md +58 -0
- package/docs/test-plans/2026-08-27-issue-tracker-choice.md +95 -0
- package/eslint.config.js +142 -0
- package/lib.es5.d.ts +19 -0
- package/package.json +36 -0
- package/progress.log +190 -0
- package/ralph_claude.sh +263 -0
- package/scripts/clone-skills.sh +52 -0
- package/specs/prd.json +172 -0
- package/src/docs-templates/domain.md +36 -0
- package/src/docs-templates/issue-tracker-github.md +228 -0
- package/src/docs-templates/issue-tracker.md +30 -0
- package/src/docs-templates/triage-labels.md +15 -0
- package/src/index.ts +184 -0
- package/src/orchestrator.ts +324 -0
- package/src/resolve.ts +73 -0
- package/src/setup.ts +157 -0
- package/src/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/src/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/src/skills/domain-modeling/SKILL.md +74 -0
- package/src/skills/domain-modeling/agents/openai.yaml +3 -0
- package/src/skills/grill/SKILL.md +7 -0
- package/src/skills/grill/agents/openai.yaml +5 -0
- package/src/skills/grilling/SKILL.md +28 -0
- package/src/skills/grilling/agents/openai.yaml +3 -0
- package/src/skills/tdd/SKILL.md +38 -0
- package/src/skills/tdd/agents/openai.yaml +3 -0
- package/src/skills/tdd/mocking.md +59 -0
- package/src/skills/tdd/tests.md +77 -0
- package/src/skills/to-spec/SKILL.md +75 -0
- package/src/skills/to-spec/agents/openai.yaml +5 -0
- package/src/skills/to-tickets/SKILL.md +105 -0
- package/src/skills/to-tickets/agents/openai.yaml +5 -0
- package/src/skills/upstream.json +5 -0
- package/tasks/prd-integration-test-coverage.md +243 -0
- package/test/integration.test.ts +636 -0
- package/test/mock-agent-echo-args.js +4 -0
- package/test/mock-agent-fail.js +3 -0
- package/test/mock-agent-pass.js +3 -0
- package/test/orchestrator.test.ts +609 -0
- package/test/setup.test.ts +417 -0
- package/tsconfig.build.json +7 -0
- package/tsconfig.json +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import yargs from 'yargs'
|
|
6
|
+
import { hideBin } from 'yargs/helpers'
|
|
7
|
+
import { select } from '@inquirer/prompts'
|
|
8
|
+
import { setup, checkGhAuth, type TrackerKind } from './setup.js'
|
|
9
|
+
import {
|
|
10
|
+
scanTickets,
|
|
11
|
+
dryRun,
|
|
12
|
+
runWaves,
|
|
13
|
+
} from './orchestrator.js'
|
|
14
|
+
import {
|
|
15
|
+
findFeatureDirs,
|
|
16
|
+
resolveIssuesDir,
|
|
17
|
+
} from './resolve.js'
|
|
18
|
+
|
|
19
|
+
export { findFeatureDirs, resolveIssuesDir }
|
|
20
|
+
|
|
21
|
+
const scriptDir = dirname(process.argv[1])
|
|
22
|
+
|
|
23
|
+
yargs(hideBin(process.argv))
|
|
24
|
+
.scriptName('nightralph')
|
|
25
|
+
.usage('$0 <command>')
|
|
26
|
+
.command(
|
|
27
|
+
'setup',
|
|
28
|
+
'Install bundled skills and doc templates',
|
|
29
|
+
y => y.option('claude', {
|
|
30
|
+
type:'boolean',
|
|
31
|
+
describe:'Also create .claude/skills/ symlinks',
|
|
32
|
+
default:false,
|
|
33
|
+
}),
|
|
34
|
+
async argv => {
|
|
35
|
+
const tracker = await select<TrackerKind>({
|
|
36
|
+
message:'Issue tracker format',
|
|
37
|
+
choices:[
|
|
38
|
+
{
|
|
39
|
+
name:'Local Markdown',
|
|
40
|
+
value:'local' as const,
|
|
41
|
+
description:'Issues as .md files in ' +
|
|
42
|
+
'.scratch/',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name:'GitHub Issues',
|
|
46
|
+
value:'github' as const,
|
|
47
|
+
description:'Issues via gh CLI',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (tracker === 'github') {
|
|
53
|
+
if (!checkGhAuth()) {
|
|
54
|
+
console.error(
|
|
55
|
+
'GitHub Issues requires the gh CLI, ' +
|
|
56
|
+
'authenticated.\n' +
|
|
57
|
+
'Install: https://cli.github.com\n' +
|
|
58
|
+
'Then run: gh auth login'
|
|
59
|
+
)
|
|
60
|
+
process.exit(2)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setup({
|
|
65
|
+
bundledSkillsDir:join(scriptDir, 'skills'),
|
|
66
|
+
docsTemplatesDir:join(
|
|
67
|
+
scriptDir, 'docs-templates'
|
|
68
|
+
),
|
|
69
|
+
projectDir:process.cwd(),
|
|
70
|
+
claude:argv.claude,
|
|
71
|
+
tracker,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
.command(
|
|
76
|
+
'$0 <provider>',
|
|
77
|
+
'Execute tickets with the given provider',
|
|
78
|
+
y => y
|
|
79
|
+
.positional('provider', {
|
|
80
|
+
type:'string',
|
|
81
|
+
describe:'Agent CLI to spawn (e.g. claude, codex)',
|
|
82
|
+
demandOption:true,
|
|
83
|
+
})
|
|
84
|
+
.option('m', {
|
|
85
|
+
alias:'model',
|
|
86
|
+
type:'string',
|
|
87
|
+
describe:'Model passed to the agent',
|
|
88
|
+
})
|
|
89
|
+
.option('dir', {
|
|
90
|
+
type:'string',
|
|
91
|
+
describe:'Path to the issues directory',
|
|
92
|
+
})
|
|
93
|
+
.option('spec', {
|
|
94
|
+
type:'string',
|
|
95
|
+
describe:'Path to the spec file',
|
|
96
|
+
})
|
|
97
|
+
.option('dry-run', {
|
|
98
|
+
type:'boolean',
|
|
99
|
+
describe:'Show wave order without running',
|
|
100
|
+
default:false,
|
|
101
|
+
})
|
|
102
|
+
.option('timeout-secs', {
|
|
103
|
+
type:'number',
|
|
104
|
+
describe:'Kill agent after N seconds',
|
|
105
|
+
default:300,
|
|
106
|
+
}),
|
|
107
|
+
async argv => {
|
|
108
|
+
await runExecute({
|
|
109
|
+
provider:argv.provider as string,
|
|
110
|
+
model:argv.model as string|undefined,
|
|
111
|
+
dir:argv.dir as string|undefined,
|
|
112
|
+
spec:argv.spec as string|undefined,
|
|
113
|
+
dryRun:argv.dryRun as boolean|undefined,
|
|
114
|
+
timeoutSecs:argv.timeoutSecs as
|
|
115
|
+
number|undefined,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
.strict()
|
|
120
|
+
.demandCommand(1, 'Specify a command or provider')
|
|
121
|
+
.help()
|
|
122
|
+
.parse()
|
|
123
|
+
|
|
124
|
+
async function runExecute (argv:{
|
|
125
|
+
provider:string
|
|
126
|
+
model?:string
|
|
127
|
+
dir?:string
|
|
128
|
+
spec?:string
|
|
129
|
+
dryRun?:boolean
|
|
130
|
+
timeoutSecs?:number
|
|
131
|
+
}):Promise<void> {
|
|
132
|
+
const { dir, spec } = await resolveIssuesDir(
|
|
133
|
+
argv.dir, argv.spec
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if (!existsSync(dir)) {
|
|
137
|
+
console.error(`Directory not found: ${dir}`)
|
|
138
|
+
process.exit(2)
|
|
139
|
+
}
|
|
140
|
+
if (!existsSync(spec)) {
|
|
141
|
+
console.error(`Spec file not found: ${spec}`)
|
|
142
|
+
process.exit(2)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const specBody = readFileSync(spec, 'utf8')
|
|
146
|
+
const tickets = scanTickets(dir)
|
|
147
|
+
|
|
148
|
+
if (tickets.length === 0) {
|
|
149
|
+
console.log('No ticket files found. Exiting.')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`Found ${tickets.length} tickets`)
|
|
154
|
+
|
|
155
|
+
const knownNums = new Set(tickets.map(t => t.num))
|
|
156
|
+
for (const t of tickets) {
|
|
157
|
+
const unknown = t.blockers.filter(
|
|
158
|
+
b => !knownNums.has(b)
|
|
159
|
+
)
|
|
160
|
+
if (unknown.length > 0) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`${t.filename}: blockers ` +
|
|
163
|
+
`[${unknown.join(', ')}] ` +
|
|
164
|
+
'not found in directory'
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
t.blockers = t.blockers.filter(
|
|
168
|
+
b => knownNums.has(b)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (argv.dryRun) {
|
|
173
|
+
dryRun(tickets, specBody)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const logsDir = join(dirname(dir), 'logs')
|
|
178
|
+
const timeoutSecs = argv.timeoutSecs ?? 300
|
|
179
|
+
await runWaves(
|
|
180
|
+
tickets, specBody,
|
|
181
|
+
argv.provider, argv.model ?? null, timeoutSecs,
|
|
182
|
+
logsDir,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
createWriteStream,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export type Ticket = {
|
|
12
|
+
num:number
|
|
13
|
+
slug:string
|
|
14
|
+
filename:string
|
|
15
|
+
filepath:string
|
|
16
|
+
body:string
|
|
17
|
+
status:string
|
|
18
|
+
blockers:number[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatErrorMessage (error:unknown):string {
|
|
22
|
+
return error instanceof Error ?
|
|
23
|
+
error.message :
|
|
24
|
+
String(error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const TICKET_RE = /^(\d+)-(.+)\.md$/
|
|
28
|
+
|
|
29
|
+
export function parseTicketFilename (
|
|
30
|
+
filename:string
|
|
31
|
+
):{ num:number; slug:string }|null {
|
|
32
|
+
const m = filename.match(TICKET_RE)
|
|
33
|
+
if (!m) return null
|
|
34
|
+
return { num:Number(m[1]), slug:m[2] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseStatus (body:string):string {
|
|
38
|
+
const m = body.match(/\*\*Status:\*\*\s*(.+)/i)
|
|
39
|
+
return m ? m[1].trim().toLowerCase() : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseBlockers (body:string):number[] {
|
|
43
|
+
const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i)
|
|
44
|
+
if (!m) return []
|
|
45
|
+
const text = m[1]
|
|
46
|
+
if (/none/i.test(text)) return []
|
|
47
|
+
const nums:number[] = []
|
|
48
|
+
const re = /(\d+)/g
|
|
49
|
+
let match
|
|
50
|
+
while ((match = re.exec(text))) {
|
|
51
|
+
nums.push(Number(match[1]))
|
|
52
|
+
}
|
|
53
|
+
return nums
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function scanTickets (dir:string):Ticket[] {
|
|
57
|
+
const files = readdirSync(dir)
|
|
58
|
+
.filter(f => TICKET_RE.test(f))
|
|
59
|
+
.sort((a, b) => a.localeCompare(
|
|
60
|
+
b,
|
|
61
|
+
undefined,
|
|
62
|
+
{ numeric:true, sensitivity:'base' }
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
return files.map(filename => {
|
|
66
|
+
const parsed = parseTicketFilename(filename)!
|
|
67
|
+
const filepath = join(dir, filename)
|
|
68
|
+
const body = readFileSync(filepath, 'utf8')
|
|
69
|
+
return {
|
|
70
|
+
num:parsed.num,
|
|
71
|
+
slug:parsed.slug,
|
|
72
|
+
filename,
|
|
73
|
+
filepath,
|
|
74
|
+
body,
|
|
75
|
+
status:parseStatus(body),
|
|
76
|
+
blockers:parseBlockers(body),
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findReadyTickets (
|
|
82
|
+
tickets:ReadonlyArray<Ticket>
|
|
83
|
+
):Ticket[] {
|
|
84
|
+
const doneNums = new Set(
|
|
85
|
+
tickets
|
|
86
|
+
.filter(t => t.status !== 'ready-for-agent')
|
|
87
|
+
.map(t => t.num)
|
|
88
|
+
)
|
|
89
|
+
return tickets.filter(t => {
|
|
90
|
+
if (t.status !== 'ready-for-agent') return false
|
|
91
|
+
return t.blockers.every(b => doneNums.has(b))
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function markDone (ticket:Ticket):void {
|
|
96
|
+
const updated = ticket.body.replace(
|
|
97
|
+
/\*\*Status:\*\*\s*.+/i,
|
|
98
|
+
'**Status:** done'
|
|
99
|
+
)
|
|
100
|
+
writeFileSync(ticket.filepath, updated)
|
|
101
|
+
ticket.status = 'done'
|
|
102
|
+
ticket.body = updated
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderPrompt (
|
|
106
|
+
specBody:string,
|
|
107
|
+
ticket:Ticket
|
|
108
|
+
):string {
|
|
109
|
+
return [
|
|
110
|
+
'---- SPEC CONTEXT ----',
|
|
111
|
+
specBody,
|
|
112
|
+
'',
|
|
113
|
+
'---- TICKET ----',
|
|
114
|
+
ticket.filename,
|
|
115
|
+
ticket.body,
|
|
116
|
+
'',
|
|
117
|
+
'---- AGENT INSTRUCTIONS ----',
|
|
118
|
+
'You are an autonomous coding agent. ' +
|
|
119
|
+
'Implement the ticket.',
|
|
120
|
+
'',
|
|
121
|
+
].join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function spawnAgent (opts:{
|
|
125
|
+
prompt:string
|
|
126
|
+
agentCmd:string
|
|
127
|
+
model:string|null
|
|
128
|
+
timeoutSecs:number
|
|
129
|
+
logPath:string
|
|
130
|
+
}):Promise<number> {
|
|
131
|
+
const args:string[] = []
|
|
132
|
+
if (opts.model) {
|
|
133
|
+
args.push('--model', opts.model)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const logStream = createWriteStream(
|
|
137
|
+
opts.logPath,
|
|
138
|
+
{ flags:'w' }
|
|
139
|
+
)
|
|
140
|
+
const proc = spawn(opts.agentCmd, args, {
|
|
141
|
+
stdio:['pipe', 'pipe', 'pipe'],
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
proc.stdout.pipe(logStream)
|
|
145
|
+
proc.stderr.pipe(logStream)
|
|
146
|
+
|
|
147
|
+
const timeout = setTimeout(() => {
|
|
148
|
+
if (!proc.killed) {
|
|
149
|
+
console.warn(
|
|
150
|
+
'Agent did not exit in ' +
|
|
151
|
+
`${opts.timeoutSecs}s; killing.`
|
|
152
|
+
)
|
|
153
|
+
proc.kill('SIGKILL')
|
|
154
|
+
}
|
|
155
|
+
}, opts.timeoutSecs * 1000)
|
|
156
|
+
|
|
157
|
+
proc.stdin.write(opts.prompt)
|
|
158
|
+
proc.stdin.end()
|
|
159
|
+
|
|
160
|
+
return new Promise<number>(resolve => {
|
|
161
|
+
proc.on('close', code => {
|
|
162
|
+
clearTimeout(timeout)
|
|
163
|
+
logStream.end()
|
|
164
|
+
resolve(code ?? 1)
|
|
165
|
+
})
|
|
166
|
+
proc.on('error', err => {
|
|
167
|
+
clearTimeout(timeout)
|
|
168
|
+
logStream.end()
|
|
169
|
+
console.error(
|
|
170
|
+
'Agent process error:',
|
|
171
|
+
formatErrorMessage(err)
|
|
172
|
+
)
|
|
173
|
+
resolve(1)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function dryRun (
|
|
179
|
+
tickets:Ticket[],
|
|
180
|
+
specBody:string
|
|
181
|
+
):void {
|
|
182
|
+
console.log('\nTickets:')
|
|
183
|
+
for (const t of tickets) {
|
|
184
|
+
const blockerStr = t.blockers.length > 0 ?
|
|
185
|
+
`blocked by [${t.blockers.join(', ')}]` :
|
|
186
|
+
'no blockers'
|
|
187
|
+
console.log(
|
|
188
|
+
` ${t.filename} ` +
|
|
189
|
+
`(${t.status}) ${blockerStr}`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log('\nSimulated wave order:')
|
|
194
|
+
const sim = new Map(
|
|
195
|
+
tickets.map(t => [t.num, t.status])
|
|
196
|
+
)
|
|
197
|
+
let wave = 1
|
|
198
|
+
while (true) {
|
|
199
|
+
const doneNums = new Set(
|
|
200
|
+
[...sim.entries()]
|
|
201
|
+
.filter(([, s]) => s !== 'ready-for-agent')
|
|
202
|
+
.map(([n]) => n)
|
|
203
|
+
)
|
|
204
|
+
const ready = tickets.filter(t => {
|
|
205
|
+
if (sim.get(t.num) !== 'ready-for-agent') {
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
return t.blockers.every(
|
|
209
|
+
b => doneNums.has(b)
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
if (ready.length === 0) break
|
|
213
|
+
console.log(
|
|
214
|
+
` Wave ${wave}: ` +
|
|
215
|
+
ready.map(t => t.filename).join(', ')
|
|
216
|
+
)
|
|
217
|
+
for (const t of ready) {
|
|
218
|
+
sim.set(t.num, 'done')
|
|
219
|
+
}
|
|
220
|
+
wave++
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const stuck = tickets.filter(
|
|
224
|
+
t => sim.get(t.num) === 'ready-for-agent'
|
|
225
|
+
)
|
|
226
|
+
if (stuck.length > 0) {
|
|
227
|
+
console.log(
|
|
228
|
+
'\nDeadlocked tickets: ' +
|
|
229
|
+
stuck.map(t => t.filename).join(', ')
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const firstReady = findReadyTickets(tickets)
|
|
234
|
+
if (firstReady.length > 0) {
|
|
235
|
+
console.log(
|
|
236
|
+
'\nPrompt for first ready ticket ' +
|
|
237
|
+
`(${firstReady[0].filename}):\n`
|
|
238
|
+
)
|
|
239
|
+
console.log(
|
|
240
|
+
renderPrompt(specBody, firstReady[0])
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function runWaves (
|
|
246
|
+
tickets:Ticket[],
|
|
247
|
+
specBody:string,
|
|
248
|
+
agentCmd:string,
|
|
249
|
+
model:string|null,
|
|
250
|
+
timeoutSecs:number,
|
|
251
|
+
logsDir:string,
|
|
252
|
+
):Promise<void> {
|
|
253
|
+
mkdirSync(logsDir, { recursive:true })
|
|
254
|
+
|
|
255
|
+
let totalCompleted = 0
|
|
256
|
+
let waveNum = 0
|
|
257
|
+
|
|
258
|
+
while (true) {
|
|
259
|
+
const ready = findReadyTickets(tickets)
|
|
260
|
+
if (ready.length === 0) break
|
|
261
|
+
|
|
262
|
+
waveNum++
|
|
263
|
+
console.log(
|
|
264
|
+
`\nWave ${waveNum}: ` +
|
|
265
|
+
ready.map(t => t.filename).join(', ')
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const results = await Promise.allSettled(
|
|
269
|
+
ready.map(async t => {
|
|
270
|
+
const prompt = renderPrompt(specBody, t)
|
|
271
|
+
const logPath = join(
|
|
272
|
+
logsDir,
|
|
273
|
+
t.filename.replace(/\.md$/, '.log')
|
|
274
|
+
)
|
|
275
|
+
console.log(` Starting ${t.filename}`)
|
|
276
|
+
const code = await spawnAgent({
|
|
277
|
+
prompt,
|
|
278
|
+
agentCmd,
|
|
279
|
+
model,
|
|
280
|
+
timeoutSecs,
|
|
281
|
+
logPath,
|
|
282
|
+
})
|
|
283
|
+
return { ticket:t, exitCode:code }
|
|
284
|
+
})
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
let waveCompleted = 0
|
|
288
|
+
for (const r of results) {
|
|
289
|
+
if (r.status === 'rejected') continue
|
|
290
|
+
const { ticket, exitCode } = r.value
|
|
291
|
+
if (exitCode === 0) {
|
|
292
|
+
markDone(ticket)
|
|
293
|
+
waveCompleted++
|
|
294
|
+
totalCompleted++
|
|
295
|
+
console.log(
|
|
296
|
+
` ${ticket.filename}: done`
|
|
297
|
+
)
|
|
298
|
+
} else {
|
|
299
|
+
console.log(
|
|
300
|
+
` ${ticket.filename}: ` +
|
|
301
|
+
`agent exited ${exitCode}`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (waveCompleted === 0) {
|
|
307
|
+
console.log(
|
|
308
|
+
'\nNo tickets completed this wave. ' +
|
|
309
|
+
'Stopping.'
|
|
310
|
+
)
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const remaining = tickets.filter(
|
|
316
|
+
t => t.status === 'ready-for-agent'
|
|
317
|
+
)
|
|
318
|
+
console.log(
|
|
319
|
+
`\nFinished: ${totalCompleted} completed` +
|
|
320
|
+
(remaining.length > 0 ?
|
|
321
|
+
`, ${remaining.length} remaining` :
|
|
322
|
+
'')
|
|
323
|
+
)
|
|
324
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { select } from '@inquirer/prompts'
|
|
4
|
+
|
|
5
|
+
export function findFeatureDirs (cwd:string):{
|
|
6
|
+
name:string;
|
|
7
|
+
dir:string;
|
|
8
|
+
spec:string
|
|
9
|
+
}[] {
|
|
10
|
+
const scratchDir = join(cwd, '.scratch')
|
|
11
|
+
if (!existsSync(scratchDir)) return []
|
|
12
|
+
const entries = readdirSync(
|
|
13
|
+
scratchDir, { withFileTypes:true }
|
|
14
|
+
)
|
|
15
|
+
const results:{ name:string; dir:string; spec:string }[] = []
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (!entry.isDirectory()) continue
|
|
18
|
+
const issuesDir = join(
|
|
19
|
+
scratchDir, entry.name, 'issues'
|
|
20
|
+
)
|
|
21
|
+
const specFile = join(
|
|
22
|
+
scratchDir, entry.name, 'spec.md'
|
|
23
|
+
)
|
|
24
|
+
if (existsSync(issuesDir)) {
|
|
25
|
+
results.push({
|
|
26
|
+
name:entry.name,
|
|
27
|
+
dir:issuesDir,
|
|
28
|
+
spec:specFile,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return results
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function resolveIssuesDir (
|
|
36
|
+
dirFlag:string|undefined,
|
|
37
|
+
specFlag:string|undefined,
|
|
38
|
+
cwd:string = process.cwd(),
|
|
39
|
+
):Promise<{ dir:string; spec:string }> {
|
|
40
|
+
if (dirFlag && specFlag) {
|
|
41
|
+
return { dir:dirFlag, spec:specFlag }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const features = findFeatureDirs(cwd)
|
|
45
|
+
|
|
46
|
+
if (features.length === 0) {
|
|
47
|
+
console.error(
|
|
48
|
+
'No .scratch/<feature>/issues/ directories ' +
|
|
49
|
+
'found.\nProvide --dir and --spec explicitly.'
|
|
50
|
+
)
|
|
51
|
+
process.exit(2)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let chosen:typeof features[0]
|
|
55
|
+
if (features.length === 1) {
|
|
56
|
+
chosen = features[0]
|
|
57
|
+
console.log(`Using feature: ${chosen.name}`)
|
|
58
|
+
} else {
|
|
59
|
+
const name = await select({
|
|
60
|
+
message:'Which feature?',
|
|
61
|
+
choices:features.map(f => ({
|
|
62
|
+
name:f.name,
|
|
63
|
+
value:f.name,
|
|
64
|
+
})),
|
|
65
|
+
})
|
|
66
|
+
chosen = features.find(f => f.name === name)!
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dir = dirFlag ?? chosen.dir
|
|
70
|
+
const spec = specFlag ?? chosen.spec
|
|
71
|
+
|
|
72
|
+
return { dir, spec }
|
|
73
|
+
}
|