* fix(auto): add missing import for resolveSkillDiscoveryMode
Used at line 687 but not imported, causing "resolveSkillDiscoveryMode is
not defined" crash on auto-mode startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): add workingDirectory to all auto-mode prompt templates
Six prompt templates (reassess-roadmap, complete-milestone, replan-slice,
run-uat, research-milestone, plan-milestone) were missing the working
directory directive. Without it, the LLM infers the main repo path from
system context and cd's there instead of staying in the worktree. This
causes artifacts to be written to the wrong location, preventing the
dispatch loop from detecting completion and triggering infinite
re-dispatches of the same unit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auto): detect mid-session resource updates and stop gracefully
Templates are read from disk on each dispatch but extension code is
loaded once at startup. If resources are re-synced mid-session (via
/gsd:update, npm update, or dev copy-resources), templates may expect
variables the in-memory code doesn't provide, causing a crash.
Add a syncedAt timestamp to managed-resources.json. Auto-mode captures
this at startup and checks before each dispatch. If resources changed,
it stops with a clear message instead of crashing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add workingDirectory to prompt template test fixtures
Tests that load prompt templates via loadPromptFromWorktree now pass the
workingDirectory variable, matching the updated templates that include
the {{workingDirectory}} directive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
7.3 KiB
TypeScript
201 lines
7.3 KiB
TypeScript
import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
|
|
import { homedir } from 'node:os'
|
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { compareSemver } from './update-check.js'
|
|
|
|
// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
|
|
// over src/resources/ (live working tree, changes with git branch).
|
|
//
|
|
// Why this matters: with `npm link`, src/resources/ points into the gsd-2 repo's
|
|
// working tree. Switching branches there changes src/resources/ for ALL projects
|
|
// that use gsd — causing stale/broken extensions to be synced to ~/.gsd/agent/.
|
|
// dist/resources/ is populated by the build step (`npm run copy-resources`) and
|
|
// reflects the built state, not the currently checked-out branch.
|
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const distResources = join(packageRoot, 'dist', 'resources')
|
|
const srcResources = join(packageRoot, 'src', 'resources')
|
|
const resourcesDir = existsSync(distResources) ? distResources : srcResources
|
|
const bundledExtensionsDir = join(resourcesDir, 'extensions')
|
|
const resourceVersionManifestName = 'managed-resources.json'
|
|
|
|
interface ManagedResourceManifest {
|
|
gsdVersion: string
|
|
syncedAt?: number
|
|
}
|
|
|
|
function isExtensionFile(name: string): boolean {
|
|
return name.endsWith('.ts') || name.endsWith('.js')
|
|
}
|
|
|
|
function resolveExtensionEntries(dir: string): string[] {
|
|
const packageJsonPath = join(dir, 'package.json')
|
|
if (existsSync(packageJsonPath)) {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
const declared = pkg?.pi?.extensions
|
|
if (Array.isArray(declared)) {
|
|
const resolved = declared
|
|
.filter((entry: unknown): entry is string => typeof entry === 'string')
|
|
.map((entry: string) => resolve(dir, entry))
|
|
.filter((entry: string) => existsSync(entry))
|
|
if (resolved.length > 0) {
|
|
return resolved
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore malformed manifests and fall back to index.ts/index.js discovery.
|
|
}
|
|
}
|
|
|
|
const indexTs = join(dir, 'index.ts')
|
|
if (existsSync(indexTs)) {
|
|
return [indexTs]
|
|
}
|
|
|
|
const indexJs = join(dir, 'index.js')
|
|
if (existsSync(indexJs)) {
|
|
return [indexJs]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
|
|
if (!existsSync(extensionsDir)) {
|
|
return []
|
|
}
|
|
|
|
const discovered: string[] = []
|
|
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
|
|
const entryPath = join(extensionsDir, entry.name)
|
|
|
|
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
|
|
discovered.push(entryPath)
|
|
continue
|
|
}
|
|
|
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
discovered.push(...resolveExtensionEntries(entryPath))
|
|
}
|
|
}
|
|
|
|
return discovered
|
|
}
|
|
|
|
function getExtensionKey(entryPath: string, extensionsDir: string): string {
|
|
const relPath = relative(extensionsDir, entryPath)
|
|
return relPath.split(/[\\/]/)[0]
|
|
}
|
|
|
|
function getManagedResourceManifestPath(agentDir: string): string {
|
|
return join(agentDir, resourceVersionManifestName)
|
|
}
|
|
|
|
function getBundledGsdVersion(): string {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
|
|
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
|
|
} catch {
|
|
return process.env.GSD_VERSION || '0.0.0'
|
|
}
|
|
}
|
|
|
|
function writeManagedResourceManifest(agentDir: string): void {
|
|
const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion(), syncedAt: Date.now() }
|
|
writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest))
|
|
}
|
|
|
|
export function readManagedResourceVersion(agentDir: string): string | null {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
|
|
return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function readManagedResourceSyncedAt(agentDir: string): number | null {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
|
|
return typeof manifest?.syncedAt === 'number' ? manifest.syncedAt : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
|
|
const managedVersion = readManagedResourceVersion(agentDir)
|
|
if (!managedVersion) {
|
|
return null
|
|
}
|
|
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
|
|
}
|
|
|
|
/**
|
|
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
|
|
*
|
|
* - extensions/ → ~/.gsd/agent/extensions/ (overwrite when version changes)
|
|
* - agents/ → ~/.gsd/agent/agents/ (overwrite when version changes)
|
|
* - skills/ → ~/.gsd/agent/skills/ (overwrite when version changes)
|
|
* - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
|
|
*
|
|
* Skips the copy when the managed-resources.json version matches the current
|
|
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
|
|
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
|
|
* copy runs once to land the new resources.
|
|
*
|
|
* Inspectable: `ls ~/.gsd/agent/extensions/`
|
|
*/
|
|
export function initResources(agentDir: string): void {
|
|
mkdirSync(agentDir, { recursive: true })
|
|
|
|
// Skip resource sync when versions match — saves ~128ms of cpSync per launch
|
|
const currentVersion = getBundledGsdVersion()
|
|
const managedVersion = readManagedResourceVersion(agentDir)
|
|
if (managedVersion && managedVersion === currentVersion) {
|
|
return
|
|
}
|
|
|
|
// Sync extensions — overwrite so updates land on next launch
|
|
const destExtensions = join(agentDir, 'extensions')
|
|
cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true })
|
|
|
|
// Sync agents
|
|
const destAgents = join(agentDir, 'agents')
|
|
const srcAgents = join(resourcesDir, 'agents')
|
|
if (existsSync(srcAgents)) {
|
|
cpSync(srcAgents, destAgents, { recursive: true, force: true })
|
|
}
|
|
|
|
// Sync skills — overwrite so updates land on next launch
|
|
const destSkills = join(agentDir, 'skills')
|
|
const srcSkills = join(resourcesDir, 'skills')
|
|
if (existsSync(srcSkills)) {
|
|
cpSync(srcSkills, destSkills, { recursive: true, force: true })
|
|
}
|
|
|
|
writeManagedResourceManifest(agentDir)
|
|
}
|
|
|
|
/**
|
|
* Constructs a DefaultResourceLoader that loads extensions from both
|
|
* ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default).
|
|
* This allows users to use extensions from either location.
|
|
*/
|
|
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
|
|
const piAgentDir = join(homedir(), '.pi', 'agent')
|
|
const piExtensionsDir = join(piAgentDir, 'extensions')
|
|
const bundledKeys = new Set(
|
|
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)),
|
|
)
|
|
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter(
|
|
(entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)),
|
|
)
|
|
|
|
return new DefaultResourceLoader({
|
|
agentDir,
|
|
additionalExtensionPaths: piExtensionPaths,
|
|
})
|
|
}
|