singularity-forge/src/resource-loader.ts

202 lines
7.8 KiB
TypeScript
Raw Normal View History

import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
import { homedir } from 'node:os'
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
2026-03-10 22:28:37 -06:00
import { fileURLToPath } from 'node:url'
import { compareSemver } from './update-check.js'
import { discoverExtensionEntryPaths } from './extension-discovery.js'
2026-03-10 22:28:37 -06:00
2026-03-14 18:47:03 +01:00
// 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
2026-03-10 22:28:37 -06:00
const bundledExtensionsDir = join(resourcesDir, 'extensions')
const resourceVersionManifestName = 'managed-resources.json'
interface ManagedResourceManifest {
gsdVersion: string
fix: auto-mode worktree path and resource sync bugs (#557) * 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>
2026-03-15 18:26:55 -06:00
syncedAt?: number
}
2026-03-10 22:28:37 -06:00
export { discoverExtensionEntryPaths } from './extension-discovery.js'
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 {
// Prefer GSD_VERSION env var (set once by loader.ts) to avoid re-reading package.json
if (process.env.GSD_VERSION && process.env.GSD_VERSION !== '0.0.0') {
return process.env.GSD_VERSION
}
try {
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
} catch {
return '0.0.0'
}
}
function writeManagedResourceManifest(agentDir: string): void {
fix: auto-mode worktree path and resource sync bugs (#557) * 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>
2026-03-15 18:26:55 -06:00
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
}
}
fix: auto-mode worktree path and resource sync bugs (#557) * 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>
2026-03-15 18:26:55 -06:00
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
const managedVersion = readManagedResourceVersion(agentDir)
if (!managedVersion) {
return null
}
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
}
/**
* Recursively makes all files and directories under dirPath owner-writable.
*
* Files copied from the Nix store inherit read-only modes (0444/0555).
* Calling this before cpSync prevents overwrite failures on subsequent upgrades,
* and calling it after ensures the next run can overwrite the copies too.
*
* Preserves existing permission bits (including executability) and only adds
* owner-write (and for directories, owner-exec) without widening group/other
* permissions.
*/
function makeTreeWritable(dirPath: string): void {
if (!existsSync(dirPath)) return
const stats = statSync(dirPath)
const isDir = stats.isDirectory()
const currentMode = stats.mode & 0o777
// Ensure owner-write; for directories also ensure owner-exec so they remain traversable.
let newMode = currentMode | 0o200
if (isDir) {
newMode |= 0o100
}
if (newMode !== currentMode) {
chmodSync(dirPath, newMode)
}
if (isDir) {
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const entryPath = join(dirPath, entry.name)
makeTreeWritable(entryPath)
}
}
}
/**
* Syncs a single bundled resource directory into the agent directory.
*
* 1. Makes the destination writable (handles Nix store read-only copies).
* 2. Removes destination subdirs that exist in source to clear stale files,
* while preserving user-created directories.
* 3. Copies source into destination.
* 4. Makes the result writable for the next upgrade cycle.
*/
function syncResourceDir(srcDir: string, destDir: string): void {
makeTreeWritable(destDir)
if (existsSync(srcDir)) {
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const target = join(destDir, entry.name)
if (existsSync(target)) rmSync(target, { recursive: true, force: true })
}
}
cpSync(srcDir, destDir, { recursive: true, force: true })
makeTreeWritable(destDir)
}
}
2026-03-10 22:28:37 -06:00
/**
* 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)
2026-03-10 22:28:37 -06:00
* - 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.
2026-03-10 22:28:37 -06:00
*
* Inspectable: `ls ~/.gsd/agent/extensions/`
*/
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true })
// Skip the full copy when the synced version already matches the running version.
// This avoids ~800ms of synchronous rmSync + cpSync on every startup.
const currentVersion = getBundledGsdVersion()
const managedVersion = readManagedResourceVersion(agentDir)
if (managedVersion && managedVersion === currentVersion) {
return
}
syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'))
syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'))
syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'))
// Ensure all newly copied files are owner-writable so the next run can
// overwrite them (covers extensions, agents, and skills in one walk).
makeTreeWritable(agentDir)
writeManagedResourceManifest(agentDir)
2026-03-10 22:28:37 -06:00
}
/**
* 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.
2026-03-10 22:28:37 -06:00
*/
// Cache bundled extension keys at module load — avoids re-scanning the extensions
// directory in buildResourceLoader() (already scanned by loader.ts for env var).
let _bundledExtensionKeys: Set<string> | null = null
function getBundledExtensionKeys(): Set<string> {
if (!_bundledExtensionKeys) {
_bundledExtensionKeys = new Set(
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)),
)
}
return _bundledExtensionKeys
}
2026-03-10 22:28:37 -06:00
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
const piAgentDir = join(homedir(), '.pi', 'agent')
const piExtensionsDir = join(piAgentDir, 'extensions')
const bundledKeys = getBundledExtensionKeys()
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter(
(entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)),
)
return new DefaultResourceLoader({
agentDir,
additionalExtensionPaths: piExtensionPaths,
})
2026-03-10 22:28:37 -06:00
}