@soleri/core 9.8.0 → 9.10.0

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 (71) hide show
  1. package/dist/brain/intelligence.d.ts.map +1 -1
  2. package/dist/brain/intelligence.js +11 -2
  3. package/dist/brain/intelligence.js.map +1 -1
  4. package/dist/brain/types.d.ts +1 -0
  5. package/dist/brain/types.d.ts.map +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +5 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/paths.d.ts +12 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +45 -2
  13. package/dist/paths.js.map +1 -1
  14. package/dist/planning/gap-patterns.d.ts.map +1 -1
  15. package/dist/planning/gap-patterns.js +4 -1
  16. package/dist/planning/gap-patterns.js.map +1 -1
  17. package/dist/runtime/admin-setup-ops.d.ts.map +1 -1
  18. package/dist/runtime/admin-setup-ops.js +29 -4
  19. package/dist/runtime/admin-setup-ops.js.map +1 -1
  20. package/dist/runtime/capture-ops.d.ts.map +1 -1
  21. package/dist/runtime/capture-ops.js +14 -6
  22. package/dist/runtime/capture-ops.js.map +1 -1
  23. package/dist/runtime/claude-md-helpers.d.ts +11 -0
  24. package/dist/runtime/claude-md-helpers.d.ts.map +1 -1
  25. package/dist/runtime/claude-md-helpers.js +18 -0
  26. package/dist/runtime/claude-md-helpers.js.map +1 -1
  27. package/dist/runtime/facades/curator-facade.d.ts.map +1 -1
  28. package/dist/runtime/facades/curator-facade.js +52 -4
  29. package/dist/runtime/facades/curator-facade.js.map +1 -1
  30. package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
  31. package/dist/runtime/facades/memory-facade.js +2 -1
  32. package/dist/runtime/facades/memory-facade.js.map +1 -1
  33. package/dist/runtime/orchestrate-ops.d.ts +12 -0
  34. package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
  35. package/dist/runtime/orchestrate-ops.js +76 -0
  36. package/dist/runtime/orchestrate-ops.js.map +1 -1
  37. package/dist/vault/vault-markdown-sync.d.ts +5 -2
  38. package/dist/vault/vault-markdown-sync.d.ts.map +1 -1
  39. package/dist/vault/vault-markdown-sync.js +13 -2
  40. package/dist/vault/vault-markdown-sync.js.map +1 -1
  41. package/dist/workflows/index.d.ts +6 -0
  42. package/dist/workflows/index.d.ts.map +1 -0
  43. package/dist/workflows/index.js +5 -0
  44. package/dist/workflows/index.js.map +1 -0
  45. package/dist/workflows/workflow-loader.d.ts +83 -0
  46. package/dist/workflows/workflow-loader.d.ts.map +1 -0
  47. package/dist/workflows/workflow-loader.js +207 -0
  48. package/dist/workflows/workflow-loader.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/__tests__/paths.test.ts +31 -0
  51. package/src/brain/intelligence.ts +15 -2
  52. package/src/brain/types.ts +1 -0
  53. package/src/enforcement/adapters/opencode.test.ts +4 -2
  54. package/src/index.ts +20 -0
  55. package/src/paths.ts +47 -2
  56. package/src/planning/gap-patterns.ts +7 -3
  57. package/src/runtime/admin-setup-ops.ts +31 -3
  58. package/src/runtime/capture-ops.test.ts +58 -1
  59. package/src/runtime/capture-ops.ts +15 -4
  60. package/src/runtime/claude-md-helpers.test.ts +81 -0
  61. package/src/runtime/claude-md-helpers.ts +25 -0
  62. package/src/runtime/facades/curator-facade.test.ts +87 -9
  63. package/src/runtime/facades/curator-facade.ts +60 -4
  64. package/src/runtime/facades/memory-facade.ts +2 -1
  65. package/src/runtime/orchestrate-ops.ts +84 -0
  66. package/src/vault/vault-markdown-sync.test.ts +40 -0
  67. package/src/vault/vault-markdown-sync.ts +16 -3
  68. package/src/workflows/index.ts +12 -0
  69. package/src/workflows/orchestrate-integration.test.ts +166 -0
  70. package/src/workflows/workflow-loader.test.ts +149 -0
  71. package/src/workflows/workflow-loader.ts +238 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Workflow loader — reads agent workflow overrides from the file tree.
3
+ *
4
+ * Each workflow is a folder under `workflows/` containing:
5
+ * - `prompt.md` — system prompt for the workflow (optional)
6
+ * - `gates.yaml` — gate definitions (optional)
7
+ * - `tools.yaml` — tool allowlist (optional)
8
+ *
9
+ * These overrides are merged into the OrchestrationPlan when
10
+ * the detected intent matches a workflow via WORKFLOW_TO_INTENT.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { z } from 'zod';
15
+ // ---------------------------------------------------------------------------
16
+ // Schemas
17
+ // ---------------------------------------------------------------------------
18
+ export const WorkflowGateSchema = z.object({
19
+ phase: z.string(),
20
+ requirement: z.string(),
21
+ check: z.string(),
22
+ });
23
+ export const WorkflowOverrideSchema = z.object({
24
+ name: z.string(),
25
+ prompt: z.string().optional(),
26
+ gates: z.array(WorkflowGateSchema).default([]),
27
+ tools: z.array(z.string()).default([]),
28
+ });
29
+ // ---------------------------------------------------------------------------
30
+ // Workflow → Intent mapping
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Maps workflow folder names to intent strings.
34
+ * Used by `getWorkflowForIntent()` to find a matching workflow.
35
+ */
36
+ export const WORKFLOW_TO_INTENT = {
37
+ 'feature-dev': 'BUILD',
38
+ 'bug-fix': 'FIX',
39
+ 'code-review': 'REVIEW',
40
+ 'component-build': 'BUILD',
41
+ 'token-migration': 'ENHANCE',
42
+ 'a11y-remediation': 'FIX',
43
+ };
44
+ // ---------------------------------------------------------------------------
45
+ // Loader
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Load all workflow overrides from an agent's `workflows/` directory.
49
+ *
50
+ * Returns an empty Map if the directory doesn't exist or can't be read
51
+ * (graceful degradation — no throw).
52
+ */
53
+ export function loadAgentWorkflows(workflowsDir) {
54
+ const workflows = new Map();
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(workflowsDir);
58
+ }
59
+ catch {
60
+ // Directory doesn't exist or can't be read — that's fine
61
+ return workflows;
62
+ }
63
+ for (const entry of entries) {
64
+ const fullPath = path.join(workflowsDir, entry);
65
+ let stat;
66
+ try {
67
+ stat = fs.statSync(fullPath);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (!stat.isDirectory())
73
+ continue;
74
+ const override = { name: entry, gates: [], tools: [] };
75
+ // Read prompt.md
76
+ const promptPath = path.join(fullPath, 'prompt.md');
77
+ try {
78
+ override.prompt = fs.readFileSync(promptPath, 'utf-8').trim();
79
+ }
80
+ catch {
81
+ // No prompt — that's fine
82
+ }
83
+ // Read gates.yaml
84
+ const gatesPath = path.join(fullPath, 'gates.yaml');
85
+ try {
86
+ const raw = fs.readFileSync(gatesPath, 'utf-8');
87
+ // Simple YAML parsing for the gates structure
88
+ const gates = parseGatesYaml(raw);
89
+ override.gates = gates;
90
+ }
91
+ catch {
92
+ // No gates — that's fine
93
+ }
94
+ // Read tools.yaml
95
+ const toolsPath = path.join(fullPath, 'tools.yaml');
96
+ try {
97
+ const raw = fs.readFileSync(toolsPath, 'utf-8');
98
+ const tools = parseToolsYaml(raw);
99
+ override.tools = tools;
100
+ }
101
+ catch {
102
+ // No tools — that's fine
103
+ }
104
+ // Only store if we got something useful
105
+ if (override.prompt || override.gates.length > 0 || override.tools.length > 0) {
106
+ workflows.set(entry, override);
107
+ }
108
+ }
109
+ return workflows;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Intent matching
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Find a workflow override that matches the given intent.
116
+ *
117
+ * Uses WORKFLOW_TO_INTENT mapping, optionally overridden by customMapping.
118
+ * Returns null if no matching workflow is found.
119
+ */
120
+ export function getWorkflowForIntent(workflows, intent, customMapping) {
121
+ const mapping = customMapping ?? WORKFLOW_TO_INTENT;
122
+ const normalizedIntent = intent.toUpperCase();
123
+ for (const [workflowName, mappedIntent] of Object.entries(mapping)) {
124
+ if (mappedIntent.toUpperCase() === normalizedIntent && workflows.has(workflowName)) {
125
+ return workflows.get(workflowName);
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Minimal YAML parsers (no external dependency)
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * Parse a simple gates.yaml file. Expected format:
135
+ *
136
+ * ```yaml
137
+ * gates:
138
+ * - phase: brainstorming
139
+ * requirement: Requirements are clear
140
+ * check: user-approval
141
+ * ```
142
+ */
143
+ function parseGatesYaml(raw) {
144
+ const gates = [];
145
+ const lines = raw.split('\n');
146
+ let current = null;
147
+ for (const line of lines) {
148
+ const trimmed = line.trim();
149
+ // Skip empty lines and the root "gates:" key
150
+ if (!trimmed || trimmed === 'gates:')
151
+ continue;
152
+ // New list item
153
+ if (trimmed.startsWith('- ')) {
154
+ if (current && current.phase && current.requirement && current.check) {
155
+ gates.push(current);
156
+ }
157
+ current = {};
158
+ // Parse inline key from "- phase: value"
159
+ const inlineMatch = trimmed.match(/^-\s+(\w+):\s*(.+)$/);
160
+ if (inlineMatch) {
161
+ const [, key, value] = inlineMatch;
162
+ if (key === 'phase' || key === 'requirement' || key === 'check') {
163
+ current[key] = value.trim();
164
+ }
165
+ }
166
+ continue;
167
+ }
168
+ // Continuation key: " requirement: value"
169
+ if (current) {
170
+ const kvMatch = trimmed.match(/^(\w+):\s*(.+)$/);
171
+ if (kvMatch) {
172
+ const [, key, value] = kvMatch;
173
+ if (key === 'phase' || key === 'requirement' || key === 'check') {
174
+ current[key] = value.trim();
175
+ }
176
+ }
177
+ }
178
+ }
179
+ // Flush last entry
180
+ if (current && current.phase && current.requirement && current.check) {
181
+ gates.push(current);
182
+ }
183
+ return gates;
184
+ }
185
+ /**
186
+ * Parse a simple tools.yaml file. Expected format:
187
+ *
188
+ * ```yaml
189
+ * tools:
190
+ * - soleri_vault op:search_intelligent
191
+ * - soleri_plan op:create_plan
192
+ * ```
193
+ */
194
+ function parseToolsYaml(raw) {
195
+ const tools = [];
196
+ const lines = raw.split('\n');
197
+ for (const line of lines) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed || trimmed === 'tools:')
200
+ continue;
201
+ if (trimmed.startsWith('- ')) {
202
+ tools.push(trimmed.slice(2).trim());
203
+ }
204
+ }
205
+ return tools;
206
+ }
207
+ //# sourceMappingURL=workflow-loader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-loader.js","sourceRoot":"","sources":["../../src/workflows/workflow-loader.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleri/core",
3
- "version": "9.8.0",
3
+ "version": "9.10.0",
4
4
  "description": "Shared engine for Soleri agents — vault, brain, planner, LLM utilities, and facade infrastructure.",
5
5
  "keywords": [
6
6
  "agent",
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { join, resolve } from 'node:path';
3
+ import { findProjectRoot } from '../paths.js';
4
+
5
+ // Resolve the monorepo root from __dirname (packages/core/src/__tests__)
6
+ const MONOREPO_ROOT = resolve(__dirname, '..', '..', '..', '..');
7
+
8
+ describe('findProjectRoot', () => {
9
+ it('finds monorepo root from package subdirectory', () => {
10
+ const packageDir = join(MONOREPO_ROOT, 'packages', 'core');
11
+ const root = findProjectRoot(packageDir);
12
+ expect(root).toBe(MONOREPO_ROOT);
13
+ });
14
+
15
+ it('finds root from deeply nested directory', () => {
16
+ const deepDir = join(MONOREPO_ROOT, 'packages', 'core', 'src', 'runtime');
17
+ const root = findProjectRoot(deepDir);
18
+ expect(root).toBe(MONOREPO_ROOT);
19
+ });
20
+
21
+ it('returns root when already at root', () => {
22
+ const root = findProjectRoot(MONOREPO_ROOT);
23
+ expect(root).toBe(MONOREPO_ROOT);
24
+ });
25
+
26
+ it('returns startDir as fallback for non-project directory', () => {
27
+ const tmpDir = '/tmp';
28
+ const result = findProjectRoot(tmpDir);
29
+ expect(result).toBe(tmpDir);
30
+ });
31
+ });
@@ -36,6 +36,8 @@ import type {
36
36
  const USAGE_MAX = 10;
37
37
  const SPREAD_MAX = 5;
38
38
  const RECENCY_DECAY_DAYS = 30;
39
+ const STRENGTH_HALFLIFE_DAYS = 90;
40
+ const STRENGTH_DECAY_FLOOR = 0.3;
39
41
  const EXTRACTION_TOOL_THRESHOLD = 3;
40
42
  const EXTRACTION_FILE_THRESHOLD = 3;
41
43
  const EXTRACTION_HIGH_FEEDBACK_RATIO = 0.8;
@@ -554,11 +556,21 @@ export class BrainIntelligence {
554
556
  const successScore = 25 * successRate;
555
557
 
556
558
  // Recency score: max(0, 25 * (1 - daysSince / RECENCY_DECAY_DAYS))
557
- const lastUsedMs = new Date(row.last_used).getTime();
559
+ // last_used is MAX(created_at) which is unixepoch() (seconds) — convert to ms
560
+ const lastUsedRaw = Number(row.last_used);
561
+ const lastUsedMs = lastUsedRaw < 1e12 ? lastUsedRaw * 1000 : lastUsedRaw;
558
562
  const daysSince = (now - lastUsedMs) / (1000 * 60 * 60 * 24);
559
563
  const recencyScore = Math.max(0, 25 * (1 - daysSince / RECENCY_DECAY_DAYS));
560
564
 
561
- const strength = usageScore + spreadScore + successScore + recencyScore;
565
+ const rawStrength = usageScore + spreadScore + successScore + recencyScore;
566
+
567
+ // Temporal decay multiplier: exponential halflife with floor
568
+ // Patterns fade over time but never vanish completely
569
+ const temporalMultiplier = Math.max(
570
+ STRENGTH_DECAY_FLOOR,
571
+ Math.exp((-Math.LN2 * daysSince) / STRENGTH_HALFLIFE_DAYS),
572
+ );
573
+ const strength = rawStrength * temporalMultiplier;
562
574
 
563
575
  const ps: PatternStrength = {
564
576
  pattern,
@@ -568,6 +580,7 @@ export class BrainIntelligence {
568
580
  spreadScore,
569
581
  successScore,
570
582
  recencyScore,
583
+ temporalMultiplier,
571
584
  usageCount: row.total,
572
585
  uniqueContexts,
573
586
  successRate,
@@ -118,6 +118,7 @@ export interface PatternStrength {
118
118
  spreadScore: number;
119
119
  successScore: number;
120
120
  recencyScore: number;
121
+ temporalMultiplier?: number;
121
122
  usageCount: number;
122
123
  uniqueContexts: number;
123
124
  successRate: number;
@@ -353,7 +353,8 @@ describe('detectHost', () => {
353
353
 
354
354
  it('detects opencode via filesystem config when env vars absent', () => {
355
355
  mockedExistsSync.mockImplementation((p: unknown) => {
356
- const path = String(p);
356
+ // Normalize to forward slashes so the check works on Windows too
357
+ const path = String(p).replace(/\\/g, '/');
357
358
  if (path.includes('opencode/opencode.json')) return true;
358
359
  if (path.includes('.claude')) return false;
359
360
  return false;
@@ -364,7 +365,8 @@ describe('detectHost', () => {
364
365
 
365
366
  it('detects claude-code via filesystem when .claude dir exists', () => {
366
367
  mockedExistsSync.mockImplementation((p: unknown) => {
367
- const path = String(p);
368
+ // Normalize to forward slashes so the check works on Windows too
369
+ const path = String(p).replace(/\\/g, '/');
368
370
  if (path.includes('.claude')) return true;
369
371
  return false;
370
372
  });
package/src/index.ts CHANGED
@@ -40,9 +40,19 @@ export {
40
40
  agentKeysPath,
41
41
  agentTemplatesDir,
42
42
  agentFlagsPath,
43
+ agentKnowledgeDir,
44
+ projectKnowledgeDir,
45
+ findProjectRoot,
43
46
  sharedVaultPath,
44
47
  } from './paths.js';
45
48
 
49
+ // ─── Vault Markdown Sync ───────────────────────────────────────────
50
+ export {
51
+ syncAllToMarkdown,
52
+ syncEntryToMarkdown,
53
+ entryToMarkdown,
54
+ } from './vault/vault-markdown-sync.js';
55
+
46
56
  // ─── Intelligence ────────────────────────────────────────────────────
47
57
  export type {
48
58
  IntelligenceEntry,
@@ -901,6 +911,16 @@ export type {
901
911
  DeclinedCategory,
902
912
  } from './operator/operator-context-types.js';
903
913
 
914
+ // ─── Workflows ─────────────────────────────────────────────────────────
915
+ export {
916
+ WorkflowGateSchema,
917
+ WorkflowOverrideSchema,
918
+ WORKFLOW_TO_INTENT,
919
+ loadAgentWorkflows,
920
+ getWorkflowForIntent,
921
+ } from './workflows/index.js';
922
+ export type { WorkflowGate, WorkflowOverride } from './workflows/index.js';
923
+
904
924
  // ─── Update Check ────────────────────────────────────────────────────
905
925
  export { checkForUpdate, buildChangelogUrl, detectBreakingChanges } from './update-check.js';
906
926
  export type { UpdateInfo } from './update-check.js';
package/src/paths.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * Override with SOLERI_HOME env var or explicit paths in agent.yaml → engine.vault.
14
14
  */
15
15
 
16
- import { join } from 'node:path';
16
+ import { join, parse as parsePath } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
- import { existsSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
19
 
20
20
  /** Root directory for all Soleri data. Default: ~/.soleri/ */
21
21
  export const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
@@ -109,6 +109,51 @@ export function agentKnowledgeDir(agentId: string): string {
109
109
  return join(agentHome(agentId), 'knowledge');
110
110
  }
111
111
 
112
+ /** Project-local knowledge directory for browsable markdown sync. */
113
+ export function projectKnowledgeDir(projectPath: string): string {
114
+ return join(projectPath, 'knowledge');
115
+ }
116
+
117
+ /**
118
+ * Walk up from startDir to find the actual project/monorepo root.
119
+ *
120
+ * Checks for (in order): .git directory, package.json with "workspaces",
121
+ * or agent.yaml. Returns the first match, or startDir as fallback.
122
+ *
123
+ * This prevents vault exports from landing in package subdirectories
124
+ * when the engine CWD is packages/cli/ instead of the monorepo root.
125
+ */
126
+ export function findProjectRoot(startDir: string): string {
127
+ let dir = startDir;
128
+ const root = parsePath(dir).root;
129
+
130
+ while (dir !== root) {
131
+ // .git is the strongest signal — always marks the project root
132
+ if (existsSync(join(dir, '.git'))) return dir;
133
+
134
+ // package.json with workspaces = monorepo root
135
+ const pkgPath = join(dir, 'package.json');
136
+ if (existsSync(pkgPath)) {
137
+ try {
138
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
139
+ if (pkg.workspaces) return dir;
140
+ } catch {
141
+ /* malformed package.json — skip */
142
+ }
143
+ }
144
+
145
+ // agent.yaml = file-tree agent root
146
+ if (existsSync(join(dir, 'agent.yaml'))) return dir;
147
+
148
+ // Walk up
149
+ const parent = join(dir, '..');
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+
154
+ return startDir;
155
+ }
156
+
112
157
  /** Shared vault path: ~/.soleri/vault.db (cross-agent intelligence) */
113
158
  export function sharedVaultPath(): string {
114
159
  return join(SOLERI_HOME, 'vault.db');
@@ -172,12 +172,16 @@ export function analyzeStructure(plan: Plan): PlanGap[] {
172
172
  }
173
173
 
174
174
  if (plan.tasks.length === 0) {
175
+ const hasApproachSteps =
176
+ plan.approach && /(?:step\s+\d|task\s+\d|\d\.\s|\d\)\s)/i.test(plan.approach);
175
177
  gaps.push(
176
178
  gap(
177
- 'critical',
179
+ hasApproachSteps ? 'major' : 'critical',
178
180
  'structure',
179
- 'Plan has no tasks.',
180
- 'Add at least one task to make the plan actionable.',
181
+ hasApproachSteps
182
+ ? 'Plan has no tasks but approach contains steps. Use `addTasks` in `plan_iterate` or pass `tasks` in `create_plan` to promote them.'
183
+ : 'Plan has no tasks.',
184
+ 'Add tasks via `create_plan` (tasks param) or `plan_iterate` (addTasks param).',
181
185
  'tasks',
182
186
  'no_tasks',
183
187
  ),
@@ -33,6 +33,7 @@ import {
33
33
  injectAtPosition,
34
34
  buildInjectionContent,
35
35
  injectEngineRulesBlock,
36
+ removeEngineRulesFromGlobal,
36
37
  } from './claude-md-helpers.js';
37
38
  import { discoverSkills, syncSkillsToClaudeCode } from '../skills/sync-skills.js';
38
39
 
@@ -295,9 +296,9 @@ export function createAdminSetupOps(runtime: AgentRuntime): OpDefinition[] {
295
296
  const filePath = targetPath ?? join(projectPath, 'CLAUDE.md');
296
297
  const existingContent = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
297
298
 
298
- // Inject engine rules if this is a global injection and agentDir is available
299
+ // Inject engine rules only for project-level injection (NOT global)
299
300
  let contentWithEngineRules = existingContent;
300
- if (isGlobal && config.agentDir) {
301
+ if (!isGlobal && config.agentDir) {
301
302
  const enginePath = join(config.agentDir, 'instructions', '_engine.md');
302
303
  if (existsSync(enginePath)) {
303
304
  const engineRulesContent = readFileSync(enginePath, 'utf-8');
@@ -460,14 +461,41 @@ export function createAdminSetupOps(runtime: AgentRuntime): OpDefinition[] {
460
461
  }
461
462
  }
462
463
 
464
+ // 4. Self-healing: strip engine rules from global files if present
465
+ const selfHealing = { engineRulesRemoved: false, agentsMdEngineRulesRemoved: false };
466
+ if (install) {
467
+ const globalClaudeMdPath = join(globalClaudeDir, 'CLAUDE.md');
468
+ if (existsSync(globalClaudeMdPath)) {
469
+ const result = removeEngineRulesFromGlobal(readFileSync(globalClaudeMdPath, 'utf-8'));
470
+ if (result.removed) {
471
+ writeFileSync(globalClaudeMdPath, result.cleaned, 'utf-8');
472
+ selfHealing.engineRulesRemoved = true;
473
+ }
474
+ }
475
+
476
+ const globalAgentsMdPath = join(homedir(), '.config', 'opencode', 'AGENTS.md');
477
+ if (existsSync(globalAgentsMdPath)) {
478
+ const result = removeEngineRulesFromGlobal(readFileSync(globalAgentsMdPath, 'utf-8'));
479
+ if (result.removed) {
480
+ writeFileSync(globalAgentsMdPath, result.cleaned, 'utf-8');
481
+ selfHealing.agentsMdEngineRulesRemoved = true;
482
+ }
483
+ }
484
+ }
485
+
463
486
  return {
464
487
  dryRun: !install,
465
488
  agentId: config.agentId,
466
489
  hookifyRules: hookifyResults,
467
490
  skills: skillsResults,
468
491
  settingsJson: settingsResults,
492
+ selfHealing,
469
493
  ...(install
470
- ? { message: 'Global setup complete' }
494
+ ? {
495
+ message: selfHealing.engineRulesRemoved
496
+ ? 'Global setup complete (engine rules removed from global CLAUDE.md)'
497
+ : 'Global setup complete',
498
+ }
471
499
  : { message: 'Dry run — pass install: true to apply' }),
472
500
  };
473
501
  },
@@ -14,12 +14,14 @@ vi.mock('../vault/scope-detector.js', () => ({
14
14
  })),
15
15
  }));
16
16
 
17
+ const mockSyncEntryToMarkdown = vi.fn(() => Promise.resolve({ written: true }));
17
18
  vi.mock('../vault/vault-markdown-sync.js', () => ({
18
- syncEntryToMarkdown: vi.fn(() => Promise.resolve()),
19
+ syncEntryToMarkdown: (...args: unknown[]) => mockSyncEntryToMarkdown(...args),
19
20
  }));
20
21
 
21
22
  vi.mock('../paths.js', () => ({
22
23
  agentKnowledgeDir: vi.fn(() => '/mock/knowledge'),
24
+ projectKnowledgeDir: vi.fn((p: string) => `/mock/project/${p}/knowledge`),
23
25
  }));
24
26
 
25
27
  // ─── Mock Runtime Factory ──────────────────────────────────────────────
@@ -213,6 +215,32 @@ describe('createCaptureOps', () => {
213
215
  const results = result.results as Array<Record<string, unknown>>;
214
216
  expect((results[0].scope as Record<string, unknown>).tier).toBe('agent');
215
217
  });
218
+
219
+ it('triggers markdown file write on successful capture', async () => {
220
+ mockSyncEntryToMarkdown.mockClear();
221
+ await findOp(ops, 'capture_knowledge').handler({
222
+ entries: [
223
+ { type: 'pattern', domain: 'testing', title: 'Sync Test', description: 'test', tags: [] },
224
+ ],
225
+ });
226
+ // fire-and-forget: allow microtask to flush
227
+ await new Promise((r) => setTimeout(r, 10));
228
+ expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
229
+ expect.objectContaining({ domain: 'testing', title: 'Sync Test' }),
230
+ '/mock/knowledge',
231
+ );
232
+ });
233
+
234
+ it('does not block capture response when sync fails', async () => {
235
+ mockSyncEntryToMarkdown.mockClear();
236
+ mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
237
+ const result = (await findOp(ops, 'capture_knowledge').handler({
238
+ entries: [{ type: 'pattern', domain: 'a', title: 'A', description: 'a', tags: [] }],
239
+ })) as Record<string, unknown>;
240
+ // Capture should still succeed despite sync error
241
+ expect(result.captured).toBe(1);
242
+ expect(result.rejected).toBe(0);
243
+ });
216
244
  });
217
245
 
218
246
  describe('capture_quick', () => {
@@ -323,6 +351,35 @@ describe('createCaptureOps', () => {
323
351
  expect(result.captured).toBe(false);
324
352
  expect((result.governance as Record<string, unknown>).action).toBe('error');
325
353
  });
354
+
355
+ it('triggers markdown file write on successful capture', async () => {
356
+ mockSyncEntryToMarkdown.mockClear();
357
+ await findOp(ops, 'capture_quick').handler({
358
+ type: 'pattern',
359
+ domain: 'testing',
360
+ title: 'Quick Sync Test',
361
+ description: 'quick test',
362
+ });
363
+ // fire-and-forget: allow microtask to flush
364
+ await new Promise((r) => setTimeout(r, 10));
365
+ expect(mockSyncEntryToMarkdown).toHaveBeenCalledWith(
366
+ expect.objectContaining({ domain: 'testing', title: 'Quick Sync Test' }),
367
+ '/mock/knowledge',
368
+ );
369
+ });
370
+
371
+ it('does not block capture response when sync fails', async () => {
372
+ mockSyncEntryToMarkdown.mockClear();
373
+ mockSyncEntryToMarkdown.mockRejectedValueOnce(new Error('disk full'));
374
+ const result = (await findOp(ops, 'capture_quick').handler({
375
+ type: 'pattern',
376
+ domain: 'testing',
377
+ title: 'Quick Fail',
378
+ description: 'test',
379
+ })) as Record<string, unknown>;
380
+ // Capture should still succeed despite sync error
381
+ expect(result.captured).toBe(true);
382
+ });
326
383
  });
327
384
 
328
385
  describe('search_intelligent', () => {
@@ -11,7 +11,7 @@ import type { AgentRuntime } from './types.js';
11
11
  import { detectScope } from '../vault/scope-detector.js';
12
12
  import type { ScopeTier, ScopeDetectionResult } from '../vault/scope-detector.js';
13
13
  import { syncEntryToMarkdown } from '../vault/vault-markdown-sync.js';
14
- import { agentKnowledgeDir } from '../paths.js';
14
+ import { agentKnowledgeDir, projectKnowledgeDir, findProjectRoot } from '../paths.js';
15
15
  import type { IntelligenceEntry } from '../intelligence/types.js';
16
16
 
17
17
  /**
@@ -189,6 +189,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
189
189
  origin: 'user',
190
190
  },
191
191
  config.agentId,
192
+ projectPath,
192
193
  );
193
194
  }
194
195
  } catch (err) {
@@ -417,6 +418,7 @@ export function createCaptureOps(runtime: AgentRuntime): OpDefinition[] {
417
418
  origin: 'user',
418
419
  },
419
420
  config.agentId,
421
+ projectPath,
420
422
  );
421
423
  return result;
422
424
  } catch (err) {
@@ -629,9 +631,18 @@ function mapType(type: string): 'pattern' | 'anti-pattern' | 'rule' | 'playbook'
629
631
  }
630
632
 
631
633
  /** Fire-and-forget markdown sync — never blocks capture, logs errors silently. */
632
- function fireAndForgetSync(entry: IntelligenceEntry, agentId: string): void {
633
- const knowledgeDir = agentKnowledgeDir(agentId);
634
- syncEntryToMarkdown(entry, knowledgeDir).catch(() => {
634
+ function fireAndForgetSync(entry: IntelligenceEntry, agentId: string, projectPath?: string): void {
635
+ // Always sync to agent home dir
636
+ const agentDir = agentKnowledgeDir(agentId);
637
+ syncEntryToMarkdown(entry, agentDir).catch(() => {
635
638
  /* non-blocking — markdown sync is best-effort */
636
639
  });
640
+
641
+ // Also sync to project-local knowledge dir if a real project path is provided
642
+ if (projectPath && projectPath !== '.') {
643
+ const projDir = projectKnowledgeDir(findProjectRoot(projectPath));
644
+ syncEntryToMarkdown(entry, projDir).catch(() => {
645
+ /* non-blocking — markdown sync is best-effort */
646
+ });
647
+ }
637
648
  }