@phuetz/code-buddy 1.2.0 → 1.3.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.
Files changed (104) hide show
  1. package/README.md +119 -24
  2. package/dist/agent/autonomous/agentic-coding-contract.d.ts +6 -6
  3. package/dist/agent/base-agent.d.ts +4 -0
  4. package/dist/agent/base-agent.js +6 -0
  5. package/dist/agent/facades/infrastructure-facade.d.ts +9 -2
  6. package/dist/agent/facades/infrastructure-facade.js +15 -6
  7. package/dist/agent/self-improvement/authored-artifact-gate.d.ts +18 -0
  8. package/dist/agent/self-improvement/authored-artifact-gate.js +42 -0
  9. package/dist/agent/self-improvement/authored-tool-runtime.d.ts +27 -0
  10. package/dist/agent/self-improvement/authored-tool-runtime.js +57 -0
  11. package/dist/agent/self-improvement/authored-tool-store.d.ts +24 -0
  12. package/dist/agent/self-improvement/authored-tool-store.js +57 -0
  13. package/dist/agent/self-improvement/llm-tool-proposer.d.ts +41 -0
  14. package/dist/agent/self-improvement/llm-tool-proposer.js +136 -0
  15. package/dist/agent/self-improvement/sandbox-scorer.d.ts +17 -0
  16. package/dist/agent/self-improvement/sandbox-scorer.js +43 -0
  17. package/dist/agent/self-improvement/self-knowledge.d.ts +8 -0
  18. package/dist/agent/self-improvement/self-knowledge.js +24 -0
  19. package/dist/agent/self-improvement/skill-benchmark.d.ts +9 -0
  20. package/dist/agent/self-improvement/skill-benchmark.js +22 -0
  21. package/dist/agent/self-improvement/skill-consolidator.d.ts +71 -0
  22. package/dist/agent/self-improvement/skill-consolidator.js +137 -0
  23. package/dist/agent/self-improvement/skill-engine.d.ts +42 -0
  24. package/dist/agent/self-improvement/skill-engine.js +87 -0
  25. package/dist/agent/self-improvement/skill-gate.d.ts +19 -0
  26. package/dist/agent/self-improvement/skill-gate.js +62 -0
  27. package/dist/agent/self-improvement/skill-mutator.d.ts +74 -0
  28. package/dist/agent/self-improvement/skill-mutator.js +223 -0
  29. package/dist/agent/self-improvement/skill-proposer.d.ts +40 -0
  30. package/dist/agent/self-improvement/skill-proposer.js +82 -0
  31. package/dist/agent/self-improvement/skill-types.d.ts +41 -0
  32. package/dist/agent/self-improvement/skill-types.js +13 -0
  33. package/dist/agent/self-improvement/tool-benchmark.d.ts +10 -0
  34. package/dist/agent/self-improvement/tool-benchmark.js +37 -0
  35. package/dist/agent/self-improvement/tool-engine.d.ts +54 -0
  36. package/dist/agent/self-improvement/tool-engine.js +101 -0
  37. package/dist/agent/self-improvement/tool-gate.d.ts +20 -0
  38. package/dist/agent/self-improvement/tool-gate.js +78 -0
  39. package/dist/agent/self-improvement/tool-proposer.d.ts +31 -0
  40. package/dist/agent/self-improvement/tool-proposer.js +34 -0
  41. package/dist/agent/self-improvement/tool-skill-mutator.d.ts +40 -0
  42. package/dist/agent/self-improvement/tool-skill-mutator.js +79 -0
  43. package/dist/agent/self-improvement/tool-types.d.ts +48 -0
  44. package/dist/agent/self-improvement/tool-types.js +9 -0
  45. package/dist/agent/self-improvement/types.d.ts +3 -1
  46. package/dist/agent/tool-handler.js +3 -0
  47. package/dist/codebuddy/providers/provider-chatgpt-responses.js +6 -1
  48. package/dist/codebuddy/tools.d.ts +7 -0
  49. package/dist/codebuddy/tools.js +40 -0
  50. package/dist/commands/cli/improve-command.js +123 -0
  51. package/dist/commands/enhanced-command-handler.js +1 -1
  52. package/dist/commands/handlers/missing-handlers.d.ts +1 -1
  53. package/dist/commands/handlers/missing-handlers.js +26 -3
  54. package/dist/commands/skills-cli/index.js +123 -0
  55. package/dist/commands/slash/builtin-commands.js +1 -1
  56. package/dist/companion/percepts.js +11 -1
  57. package/dist/context/bootstrap-loader.js +6 -23
  58. package/dist/context/import-directive-parser.d.ts +4 -0
  59. package/dist/context/import-directive-parser.js +51 -6
  60. package/dist/context/instruction-excludes.d.ts +30 -1
  61. package/dist/context/instruction-excludes.js +71 -1
  62. package/dist/context/jit-context.d.ts +8 -10
  63. package/dist/context/jit-context.js +28 -106
  64. package/dist/context/project-context.d.ts +90 -0
  65. package/dist/context/project-context.js +295 -0
  66. package/dist/daemon/autonomous-loop.d.ts +31 -1
  67. package/dist/daemon/autonomous-loop.js +80 -2
  68. package/dist/harness/contract.d.ts +28 -28
  69. package/dist/identity/identity-manager.js +3 -2
  70. package/dist/index.js +17 -1
  71. package/dist/mcp/mcp-resources.js +2 -3
  72. package/dist/sensory/dreaming.d.ts +45 -0
  73. package/dist/sensory/dreaming.js +114 -0
  74. package/dist/sensory/heartbeat-scheduler.d.ts +38 -0
  75. package/dist/sensory/heartbeat-scheduler.js +72 -0
  76. package/dist/sensory/reactions.d.ts +24 -0
  77. package/dist/sensory/reactions.js +31 -0
  78. package/dist/sensory/screen-reaction.d.ts +23 -0
  79. package/dist/sensory/screen-reaction.js +59 -0
  80. package/dist/sensory/sensory-bridge.d.ts +23 -0
  81. package/dist/sensory/sensory-bridge.js +85 -0
  82. package/dist/sensory/sensory-memory.d.ts +20 -0
  83. package/dist/sensory/sensory-memory.js +39 -0
  84. package/dist/sensory/speech-reaction.d.ts +21 -0
  85. package/dist/sensory/speech-reaction.js +83 -0
  86. package/dist/sensory/vision-reaction.d.ts +31 -0
  87. package/dist/sensory/vision-reaction.js +74 -0
  88. package/dist/server/index.js +89 -0
  89. package/dist/services/prompt-builder.d.ts +10 -0
  90. package/dist/services/prompt-builder.js +75 -9
  91. package/dist/skills/parser.js +3 -0
  92. package/dist/skills/skill-importer.d.ts +58 -0
  93. package/dist/skills/skill-importer.js +261 -0
  94. package/dist/skills/skill-sources.d.ts +20 -0
  95. package/dist/skills/skill-sources.js +102 -0
  96. package/dist/skills/types.d.ts +6 -0
  97. package/dist/tools/register-tool-handler.d.ts +25 -0
  98. package/dist/tools/register-tool-handler.js +100 -0
  99. package/dist/tools/registry.d.ts +6 -0
  100. package/dist/tools/registry.js +8 -0
  101. package/dist/utils/init-project.d.ts +7 -0
  102. package/dist/utils/init-project.js +37 -0
  103. package/dist/utils/settings-manager.d.ts +12 -0
  104. package/package.json +2 -2
@@ -1,24 +1,22 @@
1
1
  /**
2
2
  * JIT (Just-In-Time) Context Discovery
3
3
  *
4
- * Dynamically loads .codebuddy/ context files when tools access
5
- * subdirectories. Context grows organically as the agent explores,
6
- * rather than loading everything at startup.
4
+ * When a tool accesses a path, this loads the instruction files in that
5
+ * subtree that weren't already injected at startup (delegated to the unified
6
+ * `project-context` loader, sharing its dedup registry), plus two JIT-only
7
+ * concerns: auto-discovered doc pages (DOC_DIR_MAP) and path-scoped rules.
7
8
  *
8
- * Inspired by Gemini CLI's jit-context.ts
9
+ * Inspired by Gemini CLI's jit-context.
9
10
  */
10
11
  export declare const JIT_CONTEXT_PREFIX = "\n\n--- Discovered Context ---\n";
11
12
  export declare const JIT_CONTEXT_SUFFIX = "\n--- End Context ---";
12
13
  /**
13
- * Clear loaded paths cache (for testing).
14
+ * Clear caches (for testing / `/context reload`). Resets both the doc dedup set
15
+ * and the active context registry so the next pass re-scans from scratch.
14
16
  */
15
17
  export declare function clearJitCache(): void;
16
18
  /**
17
- * Discover and load context files for a given accessed path.
18
- *
19
- * Walks upward from the accessed file's directory to the project root,
20
- * checking for context files at each level. Only loads files that
21
- * haven't been loaded before in this session.
19
+ * Discover and load JIT context for a given accessed path.
22
20
  *
23
21
  * @param accessedPath - The file/directory path being accessed by a tool
24
22
  * @param projectRoot - The project root directory (stop walking here)
@@ -1,28 +1,18 @@
1
1
  /**
2
2
  * JIT (Just-In-Time) Context Discovery
3
3
  *
4
- * Dynamically loads .codebuddy/ context files when tools access
5
- * subdirectories. Context grows organically as the agent explores,
6
- * rather than loading everything at startup.
4
+ * When a tool accesses a path, this loads the instruction files in that
5
+ * subtree that weren't already injected at startup (delegated to the unified
6
+ * `project-context` loader, sharing its dedup registry), plus two JIT-only
7
+ * concerns: auto-discovered doc pages (DOC_DIR_MAP) and path-scoped rules.
7
8
  *
8
- * Inspired by Gemini CLI's jit-context.ts
9
+ * Inspired by Gemini CLI's jit-context.
9
10
  */
10
11
  import * as fs from 'fs';
11
12
  import * as path from 'path';
12
13
  import { logger } from '../utils/logger.js';
13
14
  import { discoverRulesForPath } from './rules-loader.js';
14
- import { shouldExcludeInstructionFile } from './instruction-excludes.js';
15
- import { resolveImportDirectives } from './import-directive-parser.js';
16
- /** Context file names to discover */
17
- const CONTEXT_FILES = [
18
- 'CODEBUDDY.md',
19
- 'CONTEXT.md',
20
- 'INSTRUCTIONS.md',
21
- 'AGENTS.md',
22
- 'README.md',
23
- ];
24
- /** Directories that may contain context files */
25
- const CONTEXT_DIRS = ['.codebuddy', '.claude'];
15
+ import { resolveJitContext, getActiveContextRegistry } from './project-context.js';
26
16
  /**
27
17
  * Map of source directory prefixes → relevant doc page slugs.
28
18
  * When a tool accesses a path under a key, the matching doc is auto-discovered.
@@ -45,26 +35,22 @@ const DOC_DIR_MAP = {
45
35
  'src/checkpoints': ['architecture'],
46
36
  'tests': ['testing'],
47
37
  };
48
- /** Set of already-loaded paths (avoid re-loading) */
38
+ /** Set of already-loaded doc paths (instruction files dedup via the registry). */
49
39
  const loadedPaths = new Set();
50
40
  /** Maximum context size per discovery (chars) */
51
41
  const MAX_JIT_CONTEXT_CHARS = 4000;
52
- /** Maximum directory depth to traverse upward */
53
- const MAX_UPWARD_DEPTH = 10;
54
42
  export const JIT_CONTEXT_PREFIX = '\n\n--- Discovered Context ---\n';
55
43
  export const JIT_CONTEXT_SUFFIX = '\n--- End Context ---';
56
44
  /**
57
- * Clear loaded paths cache (for testing).
45
+ * Clear caches (for testing / `/context reload`). Resets both the doc dedup set
46
+ * and the active context registry so the next pass re-scans from scratch.
58
47
  */
59
48
  export function clearJitCache() {
60
49
  loadedPaths.clear();
50
+ getActiveContextRegistry().clear();
61
51
  }
62
52
  /**
63
- * Discover and load context files for a given accessed path.
64
- *
65
- * Walks upward from the accessed file's directory to the project root,
66
- * checking for context files at each level. Only loads files that
67
- * haven't been loaded before in this session.
53
+ * Discover and load JIT context for a given accessed path.
68
54
  *
69
55
  * @param accessedPath - The file/directory path being accessed by a tool
70
56
  * @param projectRoot - The project root directory (stop walking here)
@@ -74,84 +60,18 @@ export function discoverJitContext(accessedPath, projectRoot = process.cwd()) {
74
60
  try {
75
61
  const normalizedRoot = path.resolve(projectRoot);
76
62
  const normalizedPath = path.resolve(accessedPath);
77
- // Start from the accessed file's directory
78
- let currentDir = fs.existsSync(normalizedPath) && fs.statSync(normalizedPath).isDirectory()
79
- ? normalizedPath
80
- : path.dirname(normalizedPath);
81
63
  const discoveredContent = [];
82
- let depth = 0;
83
- while (depth < MAX_UPWARD_DEPTH) {
84
- // Check if we've gone above the project root
85
- if (!currentDir.startsWith(normalizedRoot) && currentDir !== normalizedRoot) {
86
- break;
87
- }
88
- // Look for context files in this directory
89
- for (const contextFile of CONTEXT_FILES) {
90
- const filePath = path.join(currentDir, contextFile);
91
- if (!loadedPaths.has(filePath) && fs.existsSync(filePath)) {
92
- // CC10: Check instruction excludes
93
- if (shouldExcludeInstructionFile(filePath, normalizedRoot)) {
94
- logger.debug(`JIT context: excluded ${path.relative(normalizedRoot, filePath)}`);
95
- loadedPaths.add(filePath); // Mark as "seen" to avoid re-checking
96
- continue;
97
- }
98
- try {
99
- let content = fs.readFileSync(filePath, 'utf-8');
100
- if (content.trim()) {
101
- // CC9: Resolve @import directives
102
- content = resolveImportDirectives(content, {
103
- baseDir: path.dirname(filePath),
104
- projectRoot: normalizedRoot,
105
- });
106
- const relativePath = path.relative(normalizedRoot, filePath).replace(/\\/g, '/');
107
- discoveredContent.push(`[${relativePath}]\n${content.trim()}`);
108
- loadedPaths.add(filePath);
109
- logger.debug(`JIT context: loaded ${relativePath}`);
110
- }
111
- }
112
- catch { /* read error — skip */ }
113
- }
114
- }
115
- // Also check .codebuddy/ and .claude/ subdirectories
116
- for (const contextDir of CONTEXT_DIRS) {
117
- const dirPath = path.join(currentDir, contextDir);
118
- if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
119
- for (const contextFile of CONTEXT_FILES) {
120
- const filePath = path.join(dirPath, contextFile);
121
- if (!loadedPaths.has(filePath) && fs.existsSync(filePath)) {
122
- // CC10: Check instruction excludes
123
- if (shouldExcludeInstructionFile(filePath, normalizedRoot)) {
124
- logger.debug(`JIT context: excluded ${path.relative(normalizedRoot, filePath)}`);
125
- loadedPaths.add(filePath);
126
- continue;
127
- }
128
- try {
129
- let content = fs.readFileSync(filePath, 'utf-8');
130
- if (content.trim()) {
131
- // CC9: Resolve @import directives
132
- content = resolveImportDirectives(content, {
133
- baseDir: path.dirname(filePath),
134
- projectRoot: normalizedRoot,
135
- });
136
- const relativePath = path.relative(normalizedRoot, filePath).replace(/\\/g, '/');
137
- discoveredContent.push(`[${relativePath}]\n${content.trim()}`);
138
- loadedPaths.add(filePath);
139
- logger.debug(`JIT context: loaded ${relativePath}`);
140
- }
141
- }
142
- catch { /* read error — skip */ }
143
- }
144
- }
145
- }
146
- }
147
- // Move up
148
- const parent = path.dirname(currentDir);
149
- if (parent === currentDir)
150
- break; // reached filesystem root
151
- currentDir = parent;
152
- depth++;
64
+ // 1. Instruction files in the accessed subtree, via the unified loader.
65
+ // The shared active registry skips anything already injected at startup,
66
+ // so the same AGENTS.md/CODEBUDDY.md is never duplicated.
67
+ const ctx = resolveJitContext(accessedPath, {
68
+ projectRoot: normalizedRoot,
69
+ registry: getActiveContextRegistry(),
70
+ });
71
+ if (ctx.text) {
72
+ discoveredContent.push(ctx.text);
153
73
  }
154
- // Auto-discover relevant doc pages based on accessed path
74
+ // 2. Auto-discover relevant doc pages based on the accessed path.
155
75
  const relativePath = path.relative(normalizedRoot, normalizedPath).replace(/\\/g, '/');
156
76
  for (const [prefix, slugPatterns] of Object.entries(DOC_DIR_MAP)) {
157
77
  if (!relativePath.startsWith(prefix))
@@ -160,16 +80,16 @@ export function discoverJitContext(accessedPath, projectRoot = process.cwd()) {
160
80
  if (!fs.existsSync(docsDir))
161
81
  break;
162
82
  try {
163
- const docFiles = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
83
+ const docFiles = fs.readdirSync(docsDir).filter((f) => f.endsWith('.md'));
164
84
  for (const pattern of slugPatterns) {
165
- const match = docFiles.find(f => f.includes(pattern));
85
+ const match = docFiles.find((f) => f.includes(pattern));
166
86
  if (!match)
167
87
  continue;
168
88
  const docPath = path.join(docsDir, match);
169
89
  if (loadedPaths.has(docPath))
170
90
  continue;
171
91
  const content = fs.readFileSync(docPath, 'utf-8');
172
- // Only inject the first 2 sections (title + first H2) to stay compact
92
+ // Only inject the first 2 sections (title + first H2) to stay compact.
173
93
  const sections = content.split(/(?=^## )/m);
174
94
  const compact = sections.slice(0, 2).join('').trim();
175
95
  if (compact) {
@@ -181,10 +101,12 @@ export function discoverJitContext(accessedPath, projectRoot = process.cwd()) {
181
101
  break; // One doc per prefix
182
102
  }
183
103
  }
184
- catch { /* docs dir not readable */ }
104
+ catch {
105
+ /* docs dir not readable */
106
+ }
185
107
  break; // One prefix match
186
108
  }
187
- // Discover path-scoped rules matching this access
109
+ // 3. Path-scoped rules matching this access.
188
110
  const rulesContext = discoverRulesForPath(accessedPath, projectRoot);
189
111
  if (rulesContext) {
190
112
  discoveredContent.push(rulesContext);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Project Context — the canonical hierarchical loader for project-instruction
3
+ * files (the AGENTS.md / CLAUDE.md / GEMINI.md / CODEBUDDY.md hierarchy).
4
+ *
5
+ * Single source of truth that replaces the previously fragmented readers
6
+ * (jit-context, bootstrap-loader's hierarchical walk, context-files). It owns:
7
+ * - the config-driven accepted-filename list (`loadContextConfig`),
8
+ * - the per-directory probe (incl. `.codebuddy/` + `.claude/` descent),
9
+ * - variant precedence (`<name>.local.md` > `<name>.override.md` > `<name>`),
10
+ * - `@import` resolution (code-fence-safe), token/byte budgeting,
11
+ * - realpath dedup shared across the startup pass and JIT (the `ContextRegistry`).
12
+ *
13
+ * Precedence (documented once, here):
14
+ * - Tiers: global (`~/.codebuddy/`) is appended FIRST (lowest precedence),
15
+ * then the project hierarchy from project-root DOWN to cwd. Files closer to
16
+ * cwd appear later in the text → win on conflict (Codex AGENTS.md model).
17
+ * - Within a directory, every accepted filename present is COMPOSED (so
18
+ * `CODEBUDDY.md` extends `AGENTS.md`), in the configured order.
19
+ * - Within a (directory, name), the first existing variant wins and REPLACES
20
+ * the base: `<name>.local.md` (gitignored) > `<name>.override.md` > `<name>`.
21
+ * - Dedup key is `fs.realpathSync` so a `CLAUDE.md`/`GEMINI.md` symlinked to
22
+ * `AGENTS.md` collapses to one injection.
23
+ *
24
+ * Determinism: iteration is over the fixed `dirs × probeDirs × names` product
25
+ * with relative origin headers and no mtimes — the rendered text is byte-stable
26
+ * across runs, which keeps prompt caching intact.
27
+ */
28
+ export type ContextTier = 'global' | 'hierarchy' | 'jit';
29
+ export type ContextVariant = 'base' | 'override' | 'local';
30
+ export interface ContextSource {
31
+ /** Absolute path of the loaded file. */
32
+ path: string;
33
+ /** `fs.realpathSync` of `path` — the dedup key. */
34
+ realpath: string;
35
+ /** Display path (relative to project root; `~/.codebuddy/...` for global). */
36
+ relPath: string;
37
+ tier: ContextTier;
38
+ variant: ContextVariant;
39
+ /** Size of this source's rendered body (chars). */
40
+ bytes: number;
41
+ /** Whether this source was truncated to fit the budget. */
42
+ truncated: boolean;
43
+ }
44
+ export interface ResolvedContext {
45
+ /** Merged, header-decorated text, ready to inject. */
46
+ text: string;
47
+ sources: ContextSource[];
48
+ bytes: number;
49
+ truncated: boolean;
50
+ }
51
+ export interface ResolveOptions {
52
+ cwd?: string;
53
+ /** Project root; resolved via ROOT_MARKERS when omitted. */
54
+ projectRoot?: string;
55
+ /** Shared dedup registry (startup ↔ JIT). A private one is used if omitted. */
56
+ registry?: ContextRegistry;
57
+ /** Byte budget override (defaults to config `maxBytes` / `jitMaxBytes`). */
58
+ budgetBytes?: number;
59
+ /** Accepted filenames override (defaults to config). */
60
+ fileNames?: string[];
61
+ /** Home-dir override (for testing). */
62
+ homeDir?: string;
63
+ }
64
+ /** Tracks realpaths already injected so a file is never loaded twice. */
65
+ export declare class ContextRegistry {
66
+ private readonly seen;
67
+ has(realpath: string): boolean;
68
+ add(realpath: string): void;
69
+ clear(): void;
70
+ get size(): number;
71
+ }
72
+ export declare function createContextRegistry(): ContextRegistry;
73
+ export declare function setActiveContextRegistry(registry: ContextRegistry): void;
74
+ export declare function getActiveContextRegistry(): ContextRegistry;
75
+ /** Project root via ROOT_MARKERS, walking up from `cwd` (null if none found). */
76
+ export declare function findProjectRoot(cwd: string, markers?: string[]): string | null;
77
+ /** Accepted instruction filenames for a project (config-driven). */
78
+ export declare function getAcceptedFileNames(projectRoot?: string): string[];
79
+ /**
80
+ * Startup pass: global tier + the project hierarchy from root DOWN to cwd.
81
+ * Call once per prompt build. Populates `registry` (if given) so the later JIT
82
+ * pass skips files already in the system prompt.
83
+ */
84
+ export declare function resolveProjectContext(opts?: ResolveOptions): ResolvedContext;
85
+ /**
86
+ * JIT pass: when a tool touches `accessedPath`, load any context files along its
87
+ * directory chain (root → accessed dir) that weren't already injected at
88
+ * startup (skipped via the shared `registry`). Smaller per-touch budget.
89
+ */
90
+ export declare function resolveJitContext(accessedPath: string, opts?: ResolveOptions): ResolvedContext;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Project Context — the canonical hierarchical loader for project-instruction
3
+ * files (the AGENTS.md / CLAUDE.md / GEMINI.md / CODEBUDDY.md hierarchy).
4
+ *
5
+ * Single source of truth that replaces the previously fragmented readers
6
+ * (jit-context, bootstrap-loader's hierarchical walk, context-files). It owns:
7
+ * - the config-driven accepted-filename list (`loadContextConfig`),
8
+ * - the per-directory probe (incl. `.codebuddy/` + `.claude/` descent),
9
+ * - variant precedence (`<name>.local.md` > `<name>.override.md` > `<name>`),
10
+ * - `@import` resolution (code-fence-safe), token/byte budgeting,
11
+ * - realpath dedup shared across the startup pass and JIT (the `ContextRegistry`).
12
+ *
13
+ * Precedence (documented once, here):
14
+ * - Tiers: global (`~/.codebuddy/`) is appended FIRST (lowest precedence),
15
+ * then the project hierarchy from project-root DOWN to cwd. Files closer to
16
+ * cwd appear later in the text → win on conflict (Codex AGENTS.md model).
17
+ * - Within a directory, every accepted filename present is COMPOSED (so
18
+ * `CODEBUDDY.md` extends `AGENTS.md`), in the configured order.
19
+ * - Within a (directory, name), the first existing variant wins and REPLACES
20
+ * the base: `<name>.local.md` (gitignored) > `<name>.override.md` > `<name>`.
21
+ * - Dedup key is `fs.realpathSync` so a `CLAUDE.md`/`GEMINI.md` symlinked to
22
+ * `AGENTS.md` collapses to one injection.
23
+ *
24
+ * Determinism: iteration is over the fixed `dirs × probeDirs × names` product
25
+ * with relative origin headers and no mtimes — the rendered text is byte-stable
26
+ * across runs, which keeps prompt caching intact.
27
+ */
28
+ import * as fs from 'fs';
29
+ import * as path from 'path';
30
+ import * as os from 'os';
31
+ import { logger } from '../utils/logger.js';
32
+ import { resolveImportDirectives } from './import-directive-parser.js';
33
+ import { loadContextConfig, shouldExcludeInstructionFile, } from './instruction-excludes.js';
34
+ // ============================================================================
35
+ // Constants
36
+ // ============================================================================
37
+ /** Project-root markers, walked upward from cwd. */
38
+ const ROOT_MARKERS = ['.git', 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', '.hg'];
39
+ const MAX_HIERARCHY_DEPTH = 10;
40
+ /** Subdirectories probed inside every level of the hierarchy. */
41
+ const SUBDIR_PROBES = ['.codebuddy', '.claude'];
42
+ const TRUNCATION_MARKER = '\n\n... (truncated)';
43
+ // ============================================================================
44
+ // Dedup registry (shared between the startup pass and JIT)
45
+ // ============================================================================
46
+ /** Tracks realpaths already injected so a file is never loaded twice. */
47
+ export class ContextRegistry {
48
+ seen = new Set();
49
+ has(realpath) {
50
+ return this.seen.has(realpath);
51
+ }
52
+ add(realpath) {
53
+ this.seen.add(realpath);
54
+ }
55
+ clear() {
56
+ this.seen.clear();
57
+ }
58
+ get size() {
59
+ return this.seen.size;
60
+ }
61
+ }
62
+ export function createContextRegistry() {
63
+ return new ContextRegistry();
64
+ }
65
+ // Module-level "active" registry. The prompt builder publishes its per-build
66
+ // registry here (`setActiveContextRegistry`) so the standalone JIT pass — which
67
+ // has no handle to the PromptBuilder — can dedup against files already injected
68
+ // at startup. Defaults to a persistent registry until the first build.
69
+ let activeRegistry = new ContextRegistry();
70
+ export function setActiveContextRegistry(registry) {
71
+ activeRegistry = registry;
72
+ }
73
+ export function getActiveContextRegistry() {
74
+ return activeRegistry;
75
+ }
76
+ // ============================================================================
77
+ // Public API
78
+ // ============================================================================
79
+ /** Project root via ROOT_MARKERS, walking up from `cwd` (null if none found). */
80
+ export function findProjectRoot(cwd, markers = ROOT_MARKERS) {
81
+ let dir = path.resolve(cwd);
82
+ let depth = 0;
83
+ while (depth < MAX_HIERARCHY_DEPTH) {
84
+ for (const marker of markers) {
85
+ try {
86
+ if (fs.existsSync(path.join(dir, marker)))
87
+ return dir;
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ }
93
+ const parent = path.dirname(dir);
94
+ if (parent === dir)
95
+ break;
96
+ dir = parent;
97
+ depth++;
98
+ }
99
+ return null;
100
+ }
101
+ /** Accepted instruction filenames for a project (config-driven). */
102
+ export function getAcceptedFileNames(projectRoot = process.cwd()) {
103
+ return loadContextConfig(projectRoot).fileNames;
104
+ }
105
+ /**
106
+ * Startup pass: global tier + the project hierarchy from root DOWN to cwd.
107
+ * Call once per prompt build. Populates `registry` (if given) so the later JIT
108
+ * pass skips files already in the system prompt.
109
+ */
110
+ export function resolveProjectContext(opts = {}) {
111
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
112
+ const root = path.resolve(opts.projectRoot ?? findProjectRoot(cwd) ?? cwd);
113
+ const cfg = loadContextConfig(root);
114
+ const names = opts.fileNames ?? cfg.fileNames;
115
+ const homeDir = opts.homeDir ?? os.homedir();
116
+ const registry = opts.registry ?? new ContextRegistry();
117
+ const loaded = [];
118
+ // Tier 1 — global (~/.codebuddy/), lowest precedence (appended first).
119
+ const globalDir = path.join(homeDir, '.codebuddy');
120
+ loaded.push(...collectDir(globalDir, 'global', names, root, registry, homeDir, cfg));
121
+ // Tier 2 — hierarchy, project root → cwd inclusive (closest appended last).
122
+ for (const dir of getDirectoryChain(root, cwd)) {
123
+ for (const probe of [dir, ...SUBDIR_PROBES.map((s) => path.join(dir, s))]) {
124
+ loaded.push(...collectDir(probe, 'hierarchy', names, root, registry, homeDir, cfg));
125
+ }
126
+ }
127
+ return budgetMerge(loaded, opts.budgetBytes ?? cfg.maxBytes);
128
+ }
129
+ /**
130
+ * JIT pass: when a tool touches `accessedPath`, load any context files along its
131
+ * directory chain (root → accessed dir) that weren't already injected at
132
+ * startup (skipped via the shared `registry`). Smaller per-touch budget.
133
+ */
134
+ export function resolveJitContext(accessedPath, opts = {}) {
135
+ const accessedDir = path.dirname(path.resolve(accessedPath));
136
+ const root = path.resolve(opts.projectRoot ?? findProjectRoot(accessedDir) ?? accessedDir);
137
+ const cfg = loadContextConfig(root);
138
+ const names = opts.fileNames ?? cfg.fileNames;
139
+ const homeDir = opts.homeDir ?? os.homedir();
140
+ const registry = opts.registry ?? new ContextRegistry();
141
+ const loaded = [];
142
+ for (const dir of getDirectoryChain(root, accessedDir)) {
143
+ for (const probe of [dir, ...SUBDIR_PROBES.map((s) => path.join(dir, s))]) {
144
+ loaded.push(...collectDir(probe, 'jit', names, root, registry, homeDir, cfg));
145
+ }
146
+ }
147
+ return budgetMerge(loaded, opts.budgetBytes ?? cfg.jitMaxBytes);
148
+ }
149
+ /** Directory chain from `root` to `target` (inclusive). */
150
+ function getDirectoryChain(root, target) {
151
+ const resolved = path.resolve(target);
152
+ const resolvedRoot = path.resolve(root);
153
+ const chain = [resolvedRoot];
154
+ const rel = path.relative(resolvedRoot, resolved);
155
+ if (!rel || rel.startsWith('..'))
156
+ return chain;
157
+ let current = resolvedRoot;
158
+ for (const seg of rel.split(path.sep).filter(Boolean)) {
159
+ current = path.join(current, seg);
160
+ chain.push(current);
161
+ }
162
+ return chain;
163
+ }
164
+ /** Variant filenames for a base name, most-specific first. */
165
+ function variantNames(base) {
166
+ const ext = path.extname(base); // '.md'
167
+ const stem = ext ? base.slice(0, base.length - ext.length) : base;
168
+ return [
169
+ { name: `${stem}.local${ext}`, variant: 'local' },
170
+ { name: `${stem}.override${ext}`, variant: 'override' },
171
+ { name: base, variant: 'base' },
172
+ ];
173
+ }
174
+ /**
175
+ * Collect every accepted instruction file present in one directory (composed),
176
+ * applying variant precedence, excludes, realpath dedup and @import resolution.
177
+ */
178
+ function collectDir(probeDir, tier, names, root, registry, homeDir, cfg) {
179
+ const out = [];
180
+ const isGlobal = tier === 'global';
181
+ for (const base of names) {
182
+ // First existing variant wins (local > override > base) and replaces it.
183
+ let chosen = null;
184
+ for (const v of variantNames(base)) {
185
+ const filePath = path.join(probeDir, v.name);
186
+ try {
187
+ if (fs.statSync(filePath).isFile()) {
188
+ chosen = { filePath, variant: v.variant };
189
+ break;
190
+ }
191
+ }
192
+ catch {
193
+ /* not present */
194
+ }
195
+ }
196
+ if (!chosen)
197
+ continue;
198
+ // Project files honor codebuddyMdExcludes; global files are never excluded.
199
+ if (!isGlobal && shouldExcludeInstructionFile(chosen.filePath, root))
200
+ continue;
201
+ let realpath;
202
+ try {
203
+ realpath = fs.realpathSync(chosen.filePath);
204
+ }
205
+ catch {
206
+ realpath = chosen.filePath;
207
+ }
208
+ if (registry.has(realpath))
209
+ continue;
210
+ let body;
211
+ try {
212
+ body = fs.readFileSync(chosen.filePath, 'utf-8');
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ if (!body.trim())
218
+ continue;
219
+ // User-authored, trusted files: warn (don't drop) on shell-exec-like
220
+ // patterns — dropping would break legitimate AGENTS.md that documents them.
221
+ if (containsDangerousPatterns(body)) {
222
+ logger.warn(`Context file ${chosen.filePath} contains shell-exec-like patterns; loading anyway (user-authored).`);
223
+ }
224
+ body = resolveImportDirectives(body, {
225
+ baseDir: probeDir,
226
+ projectRoot: root,
227
+ homeDir,
228
+ maxDepth: cfg.importMaxDepth,
229
+ maxBytes: cfg.importMaxBytes,
230
+ });
231
+ registry.add(realpath);
232
+ out.push({
233
+ path: chosen.filePath,
234
+ realpath,
235
+ relPath: displayPath(chosen.filePath, root, homeDir, isGlobal),
236
+ tier,
237
+ variant: chosen.variant,
238
+ body,
239
+ });
240
+ }
241
+ return out;
242
+ }
243
+ /** Deterministic, abs-path-free display path for the origin header. */
244
+ function displayPath(filePath, root, homeDir, isGlobal) {
245
+ if (isGlobal) {
246
+ const rel = path.relative(path.join(homeDir, '.codebuddy'), filePath).replace(/\\/g, '/');
247
+ return `~/.codebuddy/${rel}`;
248
+ }
249
+ return path.relative(root, filePath).replace(/\\/g, '/') || path.basename(filePath);
250
+ }
251
+ /** Merge loaded sources under a budget, truncating the overflowing source. */
252
+ function budgetMerge(sources, budget) {
253
+ let total = 0;
254
+ let truncated = false;
255
+ const parts = [];
256
+ const outSources = [];
257
+ for (const s of sources) {
258
+ if (total >= budget) {
259
+ truncated = true;
260
+ break;
261
+ }
262
+ let body = s.body;
263
+ let sourceTruncated = false;
264
+ const remaining = budget - total;
265
+ if (body.length > remaining) {
266
+ body = body.slice(0, remaining) + TRUNCATION_MARKER;
267
+ sourceTruncated = true;
268
+ truncated = true;
269
+ }
270
+ parts.push(`<!-- context: ${s.relPath} (${s.tier}) -->\n${body}`);
271
+ total += body.length;
272
+ outSources.push({
273
+ path: s.path,
274
+ realpath: s.realpath,
275
+ relPath: s.relPath,
276
+ tier: s.tier,
277
+ variant: s.variant,
278
+ bytes: body.length,
279
+ truncated: sourceTruncated,
280
+ });
281
+ }
282
+ return { text: parts.join('\n\n'), sources: outSources, bytes: total, truncated };
283
+ }
284
+ /** Shell-exec-like patterns — used to WARN (not drop) on user-authored files. */
285
+ function containsDangerousPatterns(content) {
286
+ return [
287
+ /\beval\s*\(/,
288
+ /\bnew\s+Function\s*\(/,
289
+ /\brequire\s*\(\s*['"]child_process['"]\s*\)/,
290
+ /\bexec(?:Sync)?\s*\(/,
291
+ /\bspawn(?:Sync)?\s*\(/,
292
+ /<script\b/i,
293
+ ].some((p) => p.test(content));
294
+ }
295
+ //# sourceMappingURL=project-context.js.map
@@ -37,6 +37,15 @@ export interface TaskExecutionResult {
37
37
  output?: string;
38
38
  }
39
39
  export type TaskExecutor = (task: ColabTask, model: AutonomousModelChoice) => Promise<TaskExecutionResult>;
40
+ /**
41
+ * Idle-time self-improvement hook. Runs ONE bounded improvement cycle and reports
42
+ * whether it kept anything. Injected so the loop stays testable and the heavy
43
+ * engine is swappable; the default (lazy) hook runs the tool-improvement engine.
44
+ */
45
+ export type SelfImproveHook = () => Promise<{
46
+ applied: boolean;
47
+ detail: string;
48
+ }>;
40
49
  export interface AutonomousLoopConfig {
41
50
  store: FleetColabStore;
42
51
  tierConfig: ModelTierConfig;
@@ -49,9 +58,19 @@ export interface AutonomousLoopConfig {
49
58
  * goal-mode tasks complete like plain tasks (no judge gate).
50
59
  */
51
60
  goalJudge?: ColabGoalJudge;
61
+ /**
62
+ * Idle-time self-improvement. When the queue is empty AND
63
+ * `CODEBUDDY_SELF_IMPROVE=true`, run one bounded improvement cycle (cooldown-
64
+ * gated). Injected for tests; defaults to the tool-improvement engine.
65
+ */
66
+ selfImprove?: SelfImproveHook;
67
+ /** Minimum ms between idle self-improvement cycles (default 15 min). */
68
+ selfImproveCooldownMs?: number;
69
+ /** Clock for the cooldown (tests). Default Date.now. */
70
+ now?: () => number;
52
71
  }
53
72
  export interface TickResult {
54
- outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated' | 'goal_continue' | 'goal_blocked';
73
+ outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated' | 'goal_continue' | 'goal_blocked' | 'self_improved';
55
74
  taskId?: string;
56
75
  taskTitle?: string;
57
76
  model?: AutonomousModelChoice;
@@ -71,7 +90,18 @@ export declare class FleetAutonomousLoop {
71
90
  */
72
91
  private readonly failures;
73
92
  private readonly goalJudge;
93
+ private readonly selfImprove;
94
+ private readonly selfImproveCooldownMs;
95
+ private readonly now;
96
+ private lastSelfImproveAt;
74
97
  constructor(config: AutonomousLoopConfig);
98
+ /**
99
+ * Idle hook: when the queue is empty and self-improvement is opted in, run one
100
+ * bounded, cooldown-gated improvement cycle instead of sitting fully idle.
101
+ * Double-gated (idle + CODEBUDDY_SELF_IMPROVE) and bounded (cooldown + the
102
+ * engine no-ops once seed scenarios are covered). Never throws.
103
+ */
104
+ private maybeSelfImprove;
75
105
  /** Run a single autonomous tick. Never throws — failures are logged + reported. */
76
106
  tick(): Promise<TickResult>;
77
107
  /**