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.
Files changed (101) hide show
  1. package/.claude/settings.local.json +5 -0
  2. package/AGENTS.md +60 -0
  3. package/CLAUDE.md +1 -0
  4. package/PROMPT.md +95 -0
  5. package/README.md +138 -0
  6. package/WIP.md +5 -0
  7. package/dist/docs-templates/domain.md +36 -0
  8. package/dist/docs-templates/issue-tracker-github.md +228 -0
  9. package/dist/docs-templates/issue-tracker.md +30 -0
  10. package/dist/docs-templates/triage-labels.md +15 -0
  11. package/dist/index.js +154 -0
  12. package/dist/index.js.map +7 -0
  13. package/dist/meta.json +207 -0
  14. package/dist/orchestrator.js +273 -0
  15. package/dist/orchestrator.js.map +7 -0
  16. package/dist/resolve.js +71 -0
  17. package/dist/resolve.js.map +7 -0
  18. package/dist/setup.js +151 -0
  19. package/dist/setup.js.map +7 -0
  20. package/dist/skills/domain-modeling/ADR-FORMAT.md +47 -0
  21. package/dist/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  22. package/dist/skills/domain-modeling/SKILL.md +74 -0
  23. package/dist/skills/domain-modeling/agents/openai.yaml +3 -0
  24. package/dist/skills/grill/SKILL.md +7 -0
  25. package/dist/skills/grill/agents/openai.yaml +5 -0
  26. package/dist/skills/grilling/SKILL.md +28 -0
  27. package/dist/skills/grilling/agents/openai.yaml +3 -0
  28. package/dist/skills/tdd/SKILL.md +38 -0
  29. package/dist/skills/tdd/agents/openai.yaml +3 -0
  30. package/dist/skills/tdd/mocking.md +59 -0
  31. package/dist/skills/tdd/tests.md +77 -0
  32. package/dist/skills/to-spec/SKILL.md +75 -0
  33. package/dist/skills/to-spec/agents/openai.yaml +5 -0
  34. package/dist/skills/to-tickets/SKILL.md +105 -0
  35. package/dist/skills/to-tickets/agents/openai.yaml +5 -0
  36. package/dist/skills/upstream.json +5 -0
  37. package/dist/src/index.d.ts +4 -0
  38. package/dist/src/index.d.ts.map +1 -0
  39. package/dist/src/orchestrator.d.ts +31 -0
  40. package/dist/src/orchestrator.d.ts.map +1 -0
  41. package/dist/src/resolve.d.ts +10 -0
  42. package/dist/src/resolve.d.ts.map +1 -0
  43. package/dist/src/setup.d.ts +13 -0
  44. package/dist/src/setup.d.ts.map +1 -0
  45. package/dist/test/integration.test.js +848 -0
  46. package/dist/test/integration.test.js.map +7 -0
  47. package/dist/test/orchestrator.test.js +749 -0
  48. package/dist/test/orchestrator.test.js.map +7 -0
  49. package/dist/test/setup.test.js +574 -0
  50. package/dist/test/setup.test.js.map +7 -0
  51. package/docs/README.md +12 -0
  52. package/docs/agents/domain.md +36 -0
  53. package/docs/agents/issue-tracker.md +30 -0
  54. package/docs/agents/triage-labels.md +15 -0
  55. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase-2-future-work.md +144 -0
  56. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_01.md +292 -0
  57. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_02.md +212 -0
  58. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/test-requirements.md +203 -0
  59. package/docs/skills.md +58 -0
  60. package/docs/test-plans/2026-08-27-issue-tracker-choice.md +95 -0
  61. package/eslint.config.js +142 -0
  62. package/lib.es5.d.ts +19 -0
  63. package/package.json +36 -0
  64. package/progress.log +190 -0
  65. package/ralph_claude.sh +263 -0
  66. package/scripts/clone-skills.sh +52 -0
  67. package/specs/prd.json +172 -0
  68. package/src/docs-templates/domain.md +36 -0
  69. package/src/docs-templates/issue-tracker-github.md +228 -0
  70. package/src/docs-templates/issue-tracker.md +30 -0
  71. package/src/docs-templates/triage-labels.md +15 -0
  72. package/src/index.ts +184 -0
  73. package/src/orchestrator.ts +324 -0
  74. package/src/resolve.ts +73 -0
  75. package/src/setup.ts +157 -0
  76. package/src/skills/domain-modeling/ADR-FORMAT.md +47 -0
  77. package/src/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  78. package/src/skills/domain-modeling/SKILL.md +74 -0
  79. package/src/skills/domain-modeling/agents/openai.yaml +3 -0
  80. package/src/skills/grill/SKILL.md +7 -0
  81. package/src/skills/grill/agents/openai.yaml +5 -0
  82. package/src/skills/grilling/SKILL.md +28 -0
  83. package/src/skills/grilling/agents/openai.yaml +3 -0
  84. package/src/skills/tdd/SKILL.md +38 -0
  85. package/src/skills/tdd/agents/openai.yaml +3 -0
  86. package/src/skills/tdd/mocking.md +59 -0
  87. package/src/skills/tdd/tests.md +77 -0
  88. package/src/skills/to-spec/SKILL.md +75 -0
  89. package/src/skills/to-spec/agents/openai.yaml +5 -0
  90. package/src/skills/to-tickets/SKILL.md +105 -0
  91. package/src/skills/to-tickets/agents/openai.yaml +5 -0
  92. package/src/skills/upstream.json +5 -0
  93. package/tasks/prd-integration-test-coverage.md +243 -0
  94. package/test/integration.test.ts +636 -0
  95. package/test/mock-agent-echo-args.js +4 -0
  96. package/test/mock-agent-fail.js +3 -0
  97. package/test/mock-agent-pass.js +3 -0
  98. package/test/orchestrator.test.ts +609 -0
  99. package/test/setup.test.ts +417 -0
  100. package/tsconfig.build.json +7 -0
  101. 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
+ }