singularity-forge/src/headless-query.ts
Tom Boucher 54d662f17f fix(headless-query): use jiti to load extension .ts modules (#1143)
headless-query.ts imported extension modules with .js extensions, but
those files only exist as .ts (never compiled). Other code paths work
because they go through the extension loader's jiti setup, but
headless-query bypasses that as a performance optimization.

Fix: use createJiti() to dynamically import the 4 extension modules,
matching the pattern used by the extension loader. The modules are
loaded lazily in handleQuery() so the jiti overhead only applies when
the query command is actually used.

Fixes #1137
2026-03-18 08:34:18 -06:00

113 lines
4.1 KiB
TypeScript

/**
* Headless Query — `gsd headless query`
*
* Single read-only command that returns the full project snapshot as JSON
* to stdout, without spawning an LLM session. Instant (~50ms).
*
* Output: { state, next, cost }
* state — deriveState() output (phase, milestones, progress, blockers)
* next — dry-run dispatch preview (what auto-mode would do next)
* cost — aggregated parallel worker costs
*
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
* We use createJiti() here because this module is imported directly from cli.ts,
* bypassing the extension loader's jiti setup (#1137).
*/
import { createJiti } from '@mariozechner/jiti'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import type { GSDState } from './resources/extensions/gsd/types.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
async function loadExtensionModules() {
const stateModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/state.ts'), {}) as any
const dispatchModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/auto-dispatch.ts'), {}) as any
const sessionModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/session-status-io.ts'), {}) as any
const prefsModule = await jiti.import(join(__dirname, 'resources/extensions/gsd/preferences.ts'), {}) as any
return {
deriveState: stateModule.deriveState as (basePath: string) => Promise<GSDState>,
resolveDispatch: dispatchModule.resolveDispatch as (opts: any) => Promise<any>,
readAllSessionStatuses: sessionModule.readAllSessionStatuses as (basePath: string) => any[],
loadEffectiveGSDPreferences: prefsModule.loadEffectiveGSDPreferences as () => any,
}
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface QuerySnapshot {
state: GSDState
next: {
action: 'dispatch' | 'stop' | 'skip'
unitType?: string
unitId?: string
reason?: string
}
cost: {
workers: Array<{
milestoneId: string
pid: number
state: string
cost: number
lastHeartbeat: number
}>
total: number
}
}
export interface QueryResult {
exitCode: number
data?: QuerySnapshot
}
// ─── Implementation ─────────────────────────────────────────────────────────
export async function handleQuery(basePath: string): Promise<QueryResult> {
const { deriveState, resolveDispatch, readAllSessionStatuses, loadEffectiveGSDPreferences } = await loadExtensionModules()
const state = await deriveState(basePath)
// Derive next dispatch action
let next: QuerySnapshot['next']
if (!state.activeMilestone) {
next = {
action: 'stop',
reason: state.phase === 'complete' ? 'All milestones complete.' : state.nextAction,
}
} else {
const loaded = loadEffectiveGSDPreferences()
const dispatch = await resolveDispatch({
basePath,
mid: state.activeMilestone.id,
midTitle: state.activeMilestone.title,
state,
prefs: loaded?.preferences,
})
next = {
action: dispatch.action,
unitType: dispatch.action === 'dispatch' ? dispatch.unitType : undefined,
unitId: dispatch.action === 'dispatch' ? dispatch.unitId : undefined,
reason: dispatch.action === 'stop' ? dispatch.reason : undefined,
}
}
// Aggregate parallel worker costs
const statuses = readAllSessionStatuses(basePath)
const workers = statuses.map((s) => ({
milestoneId: s.milestoneId,
pid: s.pid,
state: s.state,
cost: s.cost,
lastHeartbeat: s.lastHeartbeat,
}))
const snapshot: QuerySnapshot = {
state,
next,
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
}
process.stdout.write(JSON.stringify(snapshot) + '\n')
return { exitCode: 0, data: snapshot }
}