@@ -339,21 +339,141 @@ function clearConversationLog(prefix: string): void {
339339
340340// ── Agent Loader ─────────────────────────────────────────────────────
341341
342+ /**
343+ * Parse a markdown agent file into frontmatter key-value pairs and body content.
344+ * Returns null if the file doesn't exist or has no frontmatter block.
345+ */
346+ function parseAgentFile ( filePath : string ) : { fm : Record < string , string > ; body : string } | null {
347+ if ( ! existsSync ( filePath ) ) return null ;
348+ const raw = readFileSync ( filePath , "utf-8" ) . replace ( / \r \n / g, "\n" ) ;
349+ const match = raw . match ( / ^ - - - \n ( [ \s \S ] * ?) \n - - - \n ( [ \s \S ] * ) $ / ) ;
350+ if ( ! match ) return null ;
351+ const fm : Record < string , string > = { } ;
352+ for ( const line of match [ 1 ] . split ( "\n" ) ) {
353+ const idx = line . indexOf ( ":" ) ;
354+ if ( idx > 0 ) fm [ line . slice ( 0 , idx ) . trim ( ) ] = line . slice ( idx + 1 ) . trim ( ) ;
355+ }
356+ return { fm, body : match [ 2 ] . trim ( ) } ;
357+ }
358+
359+ /** Cached package root — resolved once, reused for all agent file lookups. */
360+ let _packageRoot : string | null = null ;
361+
362+ /**
363+ * Find the taskplane package root directory.
364+ *
365+ * Strategy: this file lives at <package-root>/extensions/task-runner.ts.
366+ * When pi loads it via `-e`, it resolves the full path. We can find the
367+ * package root by searching for package.json with name "taskplane"
368+ * starting from known candidate locations.
369+ */
370+ function findPackageRoot ( ) : string {
371+ if ( _packageRoot !== null ) return _packageRoot ;
372+
373+ // Strategy 1: Walk up from this file's location via require.resolve or npm paths
374+ const candidates : string [ ] = [ ] ;
375+
376+ // The extension is loaded by pi from the installed package location.
377+ // Check well-known npm global paths.
378+ const home = process . env . HOME || process . env . USERPROFILE || "" ;
379+ if ( home ) {
380+ candidates . push ( join ( home , "AppData" , "Roaming" , "npm" , "node_modules" , "taskplane" ) ) ;
381+ candidates . push ( join ( home , ".npm-global" , "lib" , "node_modules" , "taskplane" ) ) ;
382+ }
383+ candidates . push ( join ( "/usr" , "local" , "lib" , "node_modules" , "taskplane" ) ) ;
384+
385+ // Strategy 2: resolve from pi's node_modules peer
386+ try {
387+ const piPath = process . argv [ 1 ] || "" ;
388+ const piPkgDir = resolve ( piPath , ".." , ".." ) ;
389+ candidates . push ( join ( piPkgDir , ".." , "taskplane" ) ) ;
390+ } catch { /* ignore */ }
391+
392+ // Strategy 3: Check TASKPLANE_WORKSPACE_ROOT project-local install
393+ const wsRoot = process . env . TASKPLANE_WORKSPACE_ROOT ;
394+ if ( wsRoot ) {
395+ candidates . push ( join ( wsRoot , ".pi" , "npm" , "node_modules" , "taskplane" ) ) ;
396+ candidates . push ( join ( wsRoot , "node_modules" , "taskplane" ) ) ;
397+ }
398+
399+ for ( const dir of candidates ) {
400+ try {
401+ const pkgPath = join ( dir , "package.json" ) ;
402+ if ( existsSync ( pkgPath ) ) {
403+ const pkg = JSON . parse ( readFileSync ( pkgPath , "utf-8" ) ) ;
404+ if ( pkg . name === "taskplane" ) {
405+ _packageRoot = dir ;
406+ return dir ;
407+ }
408+ }
409+ } catch { /* ignore */ }
410+ }
411+
412+ _packageRoot = "" ;
413+ return "" ;
414+ }
415+
416+ /**
417+ * Resolve the package-shipped base agent file path.
418+ * Base files live in the package's templates/agents/ directory.
419+ */
420+ function resolveBaseAgentPath ( name : string ) : string {
421+ const root = findPackageRoot ( ) ;
422+ if ( ! root ) return "" ;
423+ return join ( root , "templates" , "agents" , `${ name } .md` ) ;
424+ }
425+
426+ /**
427+ * Load an agent definition with prompt inheritance.
428+ *
429+ * Inheritance model (default: compose base + local):
430+ * 1. Load base agent from the shipped package (templates/agents/{name}.md)
431+ * 2. Load local agent from .pi/agents/{name}.md (if it exists)
432+ * 3. If local file has `standalone: true` in frontmatter, use it as-is (no base)
433+ * 4. Otherwise, compose: base prompt + separator + local content
434+ * 5. Local frontmatter values (tools, model) override base values
435+ *
436+ * If no local file exists, the base file is used directly.
437+ * If no base file exists (e.g., custom agent), local file is used as-is.
438+ */
342439function loadAgentDef ( cwd : string , name : string ) : { systemPrompt : string ; tools : string ; model : string } | null {
343- const paths = [ join ( cwd , ".pi" , "agents" , `${ name } .md` ) , join ( cwd , "agents" , `${ name } .md` ) ] ;
344- for ( const p of paths ) {
345- if ( ! existsSync ( p ) ) continue ;
346- const raw = readFileSync ( p , "utf-8" ) . replace ( / \r \n / g, "\n" ) ;
347- const match = raw . match ( / ^ - - - \n ( [ \s \S ] * ?) \n - - - \n ( [ \s \S ] * ) $ / ) ;
348- if ( ! match ) continue ;
349- const fm : Record < string , string > = { } ;
350- for ( const line of match [ 1 ] . split ( "\n" ) ) {
351- const idx = line . indexOf ( ":" ) ;
352- if ( idx > 0 ) fm [ line . slice ( 0 , idx ) . trim ( ) ] = line . slice ( idx + 1 ) . trim ( ) ;
353- }
354- return { systemPrompt : match [ 2 ] . trim ( ) , tools : fm . tools || "read,grep,find,ls" , model : fm . model || "" } ;
440+ const basePath = resolveBaseAgentPath ( name ) ;
441+ const localPaths = [ join ( cwd , ".pi" , "agents" , `${ name } .md` ) , join ( cwd , "agents" , `${ name } .md` ) ] ;
442+
443+ // Load base from package
444+ const baseDef = parseAgentFile ( basePath ) ;
445+
446+ // Load local override (first found wins)
447+ let localDef : { fm : Record < string , string > ; body : string } | null = null ;
448+ for ( const p of localPaths ) {
449+ localDef = parseAgentFile ( p ) ;
450+ if ( localDef ) break ;
451+ }
452+
453+ // No base and no local → null
454+ if ( ! baseDef && ! localDef ) return null ;
455+
456+ // Local with standalone: true → use local as-is, ignore base
457+ if ( localDef ?. fm . standalone === "true" ) {
458+ return {
459+ systemPrompt : localDef . body ,
460+ tools : localDef . fm . tools || "read,grep,find,ls" ,
461+ model : localDef . fm . model || "" ,
462+ } ;
355463 }
356- return null ;
464+
465+ // Compose base + local
466+ const basePrompt = baseDef ?. body || "" ;
467+ const localPrompt = localDef ?. body || "" ;
468+ const composedPrompt = localPrompt
469+ ? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt
470+ : basePrompt ;
471+
472+ // Local frontmatter overrides base (tools, model)
473+ const tools = localDef ?. fm . tools || baseDef ?. fm . tools || "read,grep,find,ls" ;
474+ const model = localDef ?. fm . model || baseDef ?. fm . model || "" ;
475+
476+ return { systemPrompt : composedPrompt . trim ( ) , tools, model } ;
357477}
358478
359479// ── PROMPT.md Parser ─────────────────────────────────────────────────
0 commit comments