@glrs-dev/cli 2.4.0 → 2.4.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.
@@ -0,0 +1,449 @@
1
+ import {
2
+ childLogger
3
+ } from "./chunk-Q4ULU6ER.js";
4
+ import {
5
+ detectSpecPhases,
6
+ hasSpec,
7
+ parseSpecItems
8
+ } from "./chunk-7OSEI5TF.js";
9
+
10
+ // src/plan-enrichment.ts
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
14
+ var ENRICHMENT_RATIO_THRESHOLD = 1;
15
+ function computeEnrichmentRatio(planFiles) {
16
+ let total = 0;
17
+ let enriched = 0;
18
+ for (const f of planFiles) {
19
+ let content;
20
+ try {
21
+ content = fs.readFileSync(f, "utf-8");
22
+ } catch {
23
+ continue;
24
+ }
25
+ const checkboxItems = (content.match(/^- \[[ xX]\]/gm) ?? []).length;
26
+ const headingItems = (content.match(/^###\s+\d+\.\d+\s/gm) ?? []).length;
27
+ total += headingItems > 0 ? headingItems : checkboxItems;
28
+ const hasMirror = (content.match(/^\s*-?\s*mirror:/gm) ?? []).length;
29
+ const hasContext = (content.match(/^\s*-?\s*context/gm) ?? []).length;
30
+ const hasConventions = (content.match(/^\s*-?\s*conventions:/gm) ?? []).length;
31
+ enriched += Math.min(hasMirror, hasContext, hasConventions);
32
+ }
33
+ return total > 0 ? enriched / total : 0;
34
+ }
35
+ function computeSpecEnrichmentRatio(planDir) {
36
+ if (!hasSpec(planDir)) return 0;
37
+ const phaseFiles = detectSpecPhases(planDir);
38
+ let total = 0;
39
+ let enriched = 0;
40
+ for (const phaseFile of phaseFiles) {
41
+ const phasePath = path.join(planDir, "spec", phaseFile);
42
+ const items = parseSpecItems(phasePath);
43
+ total += items.length;
44
+ for (const item of items) {
45
+ const it = item;
46
+ if (it.mirror && it.context && it.conventions) {
47
+ enriched++;
48
+ }
49
+ }
50
+ }
51
+ return total > 0 ? enriched / total : 0;
52
+ }
53
+ function buildSpecGenerationPrompt(planDir, phaseFile, content) {
54
+ const isMain = phaseFile === "main.md";
55
+ const specFileName = isMain ? "main.yaml" : phaseFile.replace(/\.md$/, ".yaml");
56
+ const specPath = `spec/${specFileName}`;
57
+ const schemaExample = isMain ? `\`\`\`yaml
58
+ # spec/main.yaml
59
+ title: "Plan title from H1"
60
+ goal: "Goal text"
61
+ constraints: "Constraints text"
62
+ phases:
63
+ - file: wave_0.yaml
64
+ completed: false
65
+ \`\`\`` : `\`\`\`yaml
66
+ # spec/${specFileName}
67
+ items:
68
+ - id: "0.1"
69
+ intent: "What this item does"
70
+ checked: false
71
+ files:
72
+ - path: src/foo.ts
73
+ isNew: false
74
+ change: "What changes"
75
+ tests:
76
+ - "test/foo.test.ts"
77
+ verify: "bun test test/foo.test.ts"
78
+ mirror: "src/similar-file.ts"
79
+ context: |
80
+ // relevant code from the file being modified
81
+ conventions: "ESM imports, named exports, bun:test"
82
+ \`\`\``;
83
+ const mainInstructions = `You are generating a YAML spec file from a markdown plan.
84
+
85
+ Read the markdown plan content below and write \`${specPath}\` (relative to the plan directory: ${planDir}) using the write/edit tool.
86
+
87
+ The output file should follow this schema:
88
+
89
+ ${schemaExample}
90
+
91
+ Extract from the markdown:
92
+ - \`title\`: the H1 heading text
93
+ - \`goal\`: the Goal section text
94
+ - \`constraints\`: the Constraints section text (if present)
95
+ - \`phases\`: one entry per phase file referenced (e.g., wave_0.md \u2192 wave_0.yaml), all with \`completed: false\`
96
+
97
+ Here is the plan file to convert:
98
+
99
+ ### ${phaseFile}
100
+ \`\`\`markdown
101
+ ${content}
102
+ \`\`\`
103
+
104
+ Write the file \`${planDir}/${specPath}\` using the write/edit tool, then respond with "SPEC_COMPLETE" when done.`;
105
+ const phaseInstructions = `You are generating a YAML spec file from a markdown plan phase, enriched with codebase context.
106
+
107
+ Read the markdown plan content below and write \`${specPath}\` (relative to the plan directory: ${planDir}) using the write/edit tool.
108
+
109
+ The output file should follow this schema:
110
+
111
+ ${schemaExample}
112
+
113
+ For each acceptance-criteria item in the plan:
114
+ 1. Extract \`id\`, \`intent\`, \`files\`, \`tests\`, \`verify\` from the markdown.
115
+ 2. Set \`checked: false\` for all items.
116
+ 3. Add enrichment fields by reading the actual codebase:
117
+ - **mirror**: Find the most similar existing file in the codebase and set \`mirror: <path>\`. This is the pattern-match target the executor will follow.
118
+ - **context**: For each file being MODIFIED (not NEW), read the relevant function/section and add 10-20 lines of the current code. For NEW files, add the key function signatures the file should export.
119
+ - **conventions**: List project-specific patterns: import style (named vs default), export pattern, test framework (vitest/jest/bun:test), naming conventions, error handling pattern.
120
+
121
+ Rules:
122
+ - Read actual files from the codebase to get accurate code pointers. Do not hallucinate file contents.
123
+ - Be concise \u2014 10-20 lines of context per file, not the whole file.
124
+ - Only add enrichment fields you can verify from the codebase.
125
+
126
+ Here is the plan file to convert:
127
+
128
+ ### ${phaseFile}
129
+ \`\`\`markdown
130
+ ${content}
131
+ \`\`\`
132
+
133
+ Write the file \`${planDir}/${specPath}\` using the write/edit tool, then respond with "SPEC_COMPLETE" when done.`;
134
+ return isMain ? mainInstructions : phaseInstructions;
135
+ }
136
+ async function enrichPlanForFastModel(cwd, planPath, logger, emitter, adapter) {
137
+ const log = logger ? childLogger(logger.root, "autopilot.enrichment") : void 0;
138
+ const resolvedPath = path.resolve(cwd, planPath);
139
+ const isDir = fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory();
140
+ let planFiles;
141
+ if (isDir) {
142
+ const entries = fs.readdirSync(resolvedPath);
143
+ planFiles = entries.filter((f) => f.endsWith(".md") && f !== "scope.md" && f !== "scope-seed.md").sort((a, b) => {
144
+ if (a === "main.md") return -1;
145
+ if (b === "main.md") return 1;
146
+ const numA = parseInt(a.replace(/[^0-9]/g, ""), 10) || 0;
147
+ const numB = parseInt(b.replace(/[^0-9]/g, ""), 10) || 0;
148
+ return numA - numB;
149
+ }).map((f) => path.join(resolvedPath, f));
150
+ } else {
151
+ planFiles = [resolvedPath];
152
+ }
153
+ emitter?.emitEvent({
154
+ type: "enrich:start",
155
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
156
+ planPath: resolvedPath,
157
+ fileCount: planFiles.length
158
+ });
159
+ const isYamlSpec = isDir && hasSpec(resolvedPath);
160
+ const wholePlanRatio = isYamlSpec ? computeSpecEnrichmentRatio(resolvedPath) : computeEnrichmentRatio(planFiles);
161
+ if (wholePlanRatio >= ENRICHMENT_RATIO_THRESHOLD) {
162
+ log?.info({ ratio: wholePlanRatio }, "Plan already enriched \u2014 skipping");
163
+ emitter?.emitEvent({
164
+ type: "enrich:done",
165
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
166
+ filesProcessed: 0
167
+ });
168
+ return;
169
+ }
170
+ if (isDir) {
171
+ fs.mkdirSync(path.join(resolvedPath, "spec"), { recursive: true });
172
+ }
173
+ const existingMainYaml = path.join(resolvedPath, "spec", "main.yaml");
174
+ let savedCompletionStates = null;
175
+ try {
176
+ if (fs.existsSync(existingMainYaml)) {
177
+ const content = fs.readFileSync(existingMainYaml, "utf-8");
178
+ const raw = yamlParse(content);
179
+ if (raw && Array.isArray(raw.phases)) {
180
+ savedCompletionStates = /* @__PURE__ */ new Map();
181
+ for (const phase of raw.phases) {
182
+ if (phase.file && phase.completed === true) {
183
+ savedCompletionStates.set(phase.file, true);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ } catch {
189
+ }
190
+ log?.info({ planPath: resolvedPath, fileCount: planFiles.length }, "Starting enrichment");
191
+ log?.info("Starting OpenCode server for enrichment...");
192
+ if (!adapter) {
193
+ throw new Error("enrichPlanForFastModel: adapter is required");
194
+ }
195
+ const handle = await adapter.start({ cwd });
196
+ log?.info({ agentId: handle.id }, "Agent ready for enrichment");
197
+ let enrichmentCumulativeCost = 0;
198
+ try {
199
+ for (const f of planFiles) {
200
+ const rel = path.relative(cwd, f);
201
+ const phaseFile = path.basename(f);
202
+ const specFileName = phaseFile === "main.md" ? "main.yaml" : phaseFile.replace(/\.md$/, ".yaml");
203
+ const specPath = isDir ? path.join(resolvedPath, "spec", specFileName) : null;
204
+ if (specPath && fs.existsSync(specPath)) {
205
+ if (phaseFile === "main.md") {
206
+ log?.info({ file: rel }, "Spec already exists \u2014 skipping");
207
+ emitter?.emitEvent({
208
+ type: "enrich:file:skip",
209
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
210
+ file: rel,
211
+ reason: "spec already exists"
212
+ });
213
+ continue;
214
+ }
215
+ const phaseItems = parseSpecItems(specPath);
216
+ if (phaseItems.some((it) => it.checked)) {
217
+ log?.info({ file: rel }, "Phase has checked items \u2014 skipping re-enrichment");
218
+ emitter?.emitEvent({
219
+ type: "enrich:file:skip",
220
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
221
+ file: rel,
222
+ reason: "phase has checked items (in-progress)"
223
+ });
224
+ continue;
225
+ }
226
+ const phaseTotal = phaseItems.length;
227
+ const phaseEnriched = phaseItems.filter(
228
+ (it) => {
229
+ const item = it;
230
+ return item.mirror && item.context && item.conventions;
231
+ }
232
+ ).length;
233
+ const phaseRatio = phaseTotal > 0 ? phaseEnriched / phaseTotal : 0;
234
+ if (phaseRatio >= ENRICHMENT_RATIO_THRESHOLD) {
235
+ log?.info({ file: rel, ratio: phaseRatio }, "Phase spec already enriched \u2014 skipping");
236
+ emitter?.emitEvent({
237
+ type: "enrich:file:skip",
238
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
239
+ file: rel,
240
+ reason: `already enriched (${Math.round(phaseRatio * 100)}%)`
241
+ });
242
+ continue;
243
+ }
244
+ }
245
+ let content;
246
+ try {
247
+ content = fs.readFileSync(f, "utf-8");
248
+ } catch (err) {
249
+ const msg = err instanceof Error ? err.message : String(err);
250
+ log?.warn({ file: rel, err: msg }, "File read failed \u2014 skipping");
251
+ emitter?.emitEvent({
252
+ type: "enrich:file:error",
253
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
254
+ file: rel,
255
+ error: `read failed: ${msg}`
256
+ });
257
+ continue;
258
+ }
259
+ if (phaseFile !== "main.md") {
260
+ const checkboxItems = (content.match(/^- \[[ xX]\]/gm) ?? []).length;
261
+ const headingItems = (content.match(/^###\s+\d+\.\d+\s/gm) ?? []).length;
262
+ const itemCount = Math.max(checkboxItems, headingItems);
263
+ if (itemCount === 0) {
264
+ log?.info({ file: rel }, "No enrichable items \u2014 skipping");
265
+ emitter?.emitEvent({
266
+ type: "enrich:file:skip",
267
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
268
+ file: rel,
269
+ reason: "no enrichable items"
270
+ });
271
+ continue;
272
+ }
273
+ }
274
+ emitter?.emitEvent({
275
+ type: "enrich:file:start",
276
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
277
+ file: rel
278
+ });
279
+ let sessionId;
280
+ try {
281
+ sessionId = await adapter.createSession(handle, {
282
+ agentName: "prime"
283
+ });
284
+ } catch (err) {
285
+ const msg = err instanceof Error ? err.message : String(err);
286
+ log?.warn({ file: rel, err: msg }, "createSession failed \u2014 skipping");
287
+ emitter?.emitEvent({
288
+ type: "enrich:file:error",
289
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
290
+ file: rel,
291
+ error: `createSession failed: ${msg}`
292
+ });
293
+ continue;
294
+ }
295
+ const prompt = buildSpecGenerationPrompt(resolvedPath, phaseFile, content);
296
+ let toolCalls = 0;
297
+ let fileCost = 0;
298
+ try {
299
+ log?.info({ file: rel }, "Starting spec generation session");
300
+ const sessionResult = await adapter.sendAndWait(handle, {
301
+ sessionId,
302
+ message: prompt,
303
+ stallMs: 5 * 60 * 1e3,
304
+ // 5 min stall — per-file scope is smaller
305
+ onToolCall: (toolName, firstArg) => {
306
+ toolCalls++;
307
+ log?.debug({ file: rel, toolName, firstArg, toolCalls }, "Spec generation tool call");
308
+ emitter?.emitEvent({
309
+ type: "tool:call",
310
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
311
+ toolName,
312
+ ...firstArg ? { firstArg } : {},
313
+ iteration: 0
314
+ });
315
+ },
316
+ onTextDelta: () => {
317
+ },
318
+ onCostUpdate: (cost, tokens) => {
319
+ fileCost = cost;
320
+ emitter?.emitEvent({
321
+ type: "cost:update",
322
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
323
+ cumulativeCostUsd: cost,
324
+ isEstimated: false,
325
+ iteration: 0,
326
+ tokensIn: tokens.input,
327
+ tokensOut: tokens.output
328
+ });
329
+ }
330
+ });
331
+ if (sessionResult.kind === "error") {
332
+ const rawMsg = "message" in sessionResult ? sessionResult.message : "unknown error";
333
+ const errMsg = adapter.enhanceError ? await adapter.enhanceError(rawMsg) : rawMsg;
334
+ log?.error({ file: rel, err: errMsg }, "Session errored");
335
+ emitter?.emitEvent({
336
+ type: "enrich:file:error",
337
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
338
+ file: rel,
339
+ error: errMsg
340
+ });
341
+ continue;
342
+ }
343
+ if (sessionResult.kind === "stall") {
344
+ log?.error({ file: rel }, "Session stalled");
345
+ emitter?.emitEvent({
346
+ type: "enrich:file:error",
347
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
348
+ file: rel,
349
+ error: "session stalled"
350
+ });
351
+ continue;
352
+ }
353
+ const response = await adapter.getLastResponse(handle, sessionId);
354
+ log?.debug({ file: rel, toolCalls, responseLength: response.length }, "Session response received");
355
+ if (adapter.getSessionStats) {
356
+ try {
357
+ const stats = await adapter.getSessionStats(handle, sessionId);
358
+ enrichmentCumulativeCost += stats.cost;
359
+ if (enrichmentCumulativeCost > 0 || stats.tokensIn > 0 || stats.tokensOut > 0) {
360
+ emitter?.emitEvent({
361
+ type: "cost:update",
362
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
363
+ cumulativeCostUsd: enrichmentCumulativeCost,
364
+ isEstimated: false,
365
+ iteration: 0,
366
+ tokensIn: stats.tokensIn,
367
+ tokensOut: stats.tokensOut
368
+ });
369
+ }
370
+ } catch {
371
+ }
372
+ }
373
+ if (!response.includes("SPEC_COMPLETE")) {
374
+ log?.warn({ file: rel, toolCalls }, "Spec generation session did not complete cleanly");
375
+ emitter?.emitEvent({
376
+ type: "enrich:file:error",
377
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
378
+ file: rel,
379
+ error: "spec generation session did not complete cleanly"
380
+ });
381
+ } else if (specPath && !fs.existsSync(specPath)) {
382
+ log?.warn({ file: rel, toolCalls }, "Spec generation session did not complete cleanly");
383
+ emitter?.emitEvent({
384
+ type: "enrich:file:error",
385
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
386
+ file: rel,
387
+ error: `SPEC_COMPLETE received but spec/${specFileName} was not written`
388
+ });
389
+ } else {
390
+ log?.info({ file: rel, toolCalls }, "Spec generated successfully");
391
+ emitter?.emitEvent({
392
+ type: "enrich:file:done",
393
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
394
+ file: rel,
395
+ toolCalls,
396
+ ...specPath ? { specFile: `spec/${specFileName}` } : {}
397
+ });
398
+ }
399
+ } catch (err) {
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ log?.error({ file: rel, err: msg, toolCalls }, "Spec generation failed \u2014 continuing");
402
+ emitter?.emitEvent({
403
+ type: "enrich:file:error",
404
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
405
+ file: rel,
406
+ error: `spec generation failed: ${msg}`
407
+ });
408
+ }
409
+ }
410
+ } finally {
411
+ await adapter.shutdown(handle);
412
+ }
413
+ if (savedCompletionStates && savedCompletionStates.size > 0) {
414
+ try {
415
+ const mainYamlPath = path.join(resolvedPath, "spec", "main.yaml");
416
+ if (fs.existsSync(mainYamlPath)) {
417
+ let content = fs.readFileSync(mainYamlPath, "utf-8");
418
+ const raw = yamlParse(content);
419
+ if (raw && Array.isArray(raw.phases)) {
420
+ let modified = false;
421
+ for (const phase of raw.phases) {
422
+ if (phase.file && savedCompletionStates.has(phase.file) && phase.completed !== true) {
423
+ phase.completed = true;
424
+ modified = true;
425
+ }
426
+ }
427
+ if (modified) {
428
+ fs.writeFileSync(mainYamlPath, yamlStringify(raw), "utf-8");
429
+ log?.info({ restored: savedCompletionStates.size }, "Restored phase completion states after enrichment");
430
+ }
431
+ }
432
+ }
433
+ } catch (err) {
434
+ log?.warn({ err }, "Failed to restore phase completion states \u2014 phases may need manual re-marking");
435
+ }
436
+ }
437
+ emitter?.emitEvent({
438
+ type: "enrich:done",
439
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
440
+ filesProcessed: planFiles.length
441
+ });
442
+ }
443
+
444
+ export {
445
+ ENRICHMENT_RATIO_THRESHOLD,
446
+ computeEnrichmentRatio,
447
+ computeSpecEnrichmentRatio,
448
+ enrichPlanForFastModel
449
+ };