Institutional Memory with Learning Files: Teaching LLMs Past Decisions

James Phoenix
James Phoenix

Summary

LLMs forget past decisions and repeatedly propose rejected ideas. Learning files (*-learning.json) track decisions, rationales, and context so LLMs can learn from past choices. When integrated with automated scanners, they prevent redundant suggestions, preserve institutional knowledge across team changes, and create an audit trail of all architectural decisions.

The Problem

LLMs are stateless—they forget past decisions and context between sessions. Teams using AI-assisted tools face recurring issues: (1) LLMs re-propose ideas already rejected, wasting review time (2) Rationales for decisions are lost when team members leave (3) New team members lack context on why certain patterns were chosen or rejected (4) No audit trail exists for architectural decisions made with AI assistance.

The Solution

Maintain structured learning files (*-learning.json) that track decisions, rationales, alternatives, and outcomes. LLMs read these files before generating suggestions, learning from past choices. Files capture: approved/rejected status, decision rationale, context, alternatives considered, proposer/approver, and dates. Integrated with automated scanners, they prevent redundant suggestions and preserve institutional knowledge.

The Problem

LLMs are stateless. Every conversation starts from scratch, with no memory of previous interactions or decisions.

For teams using AI-assisted development tools, this creates painful recurring problems:

Problem 1: Re-Proposing Rejected Ideas

Scenario: Your team runs an automated ESLint scanner that suggests new rules.

Week 1: LLM suggests adding "max-lines-per-function: 50"
Team: "Rejected - too opinionated, breaks valid long integration tests"

Week 5: LLM suggests adding "max-lines-per-function: 50"
Team: "We already rejected this!"

Week 9: LLM suggests adding "max-lines-per-function: 50"
Team: "WHY DOES IT KEEP SUGGESTING THIS?"

Impact: Wasted review time, team frustration, loss of confidence in AI tools.

Problem 2: Lost Rationales

Scenario: A decision is made to use factory functions instead of classes.

2024-03: Senior engineer chooses factory pattern for dependency injection
Rationale: Easier to mock, no 'this' binding issues, better tree-shaking

2024-09: Senior engineer leaves company

2024-11: New team member asks: "Why are we using factories?"
Team: "Uh... I think there was a reason? Check the commit history?"

2025-01: Another new member: "Can we switch to classes? Factories feel weird."
Team: "We don't remember why we chose factories. Let's debate it again."

Impact: Decisions are relitigated, time is wasted, context is permanently lost.

Problem 3: No Audit Trail

Scenario: Multiple architectural decisions made over months.

Month 1: Decided to use tRPC instead of REST
Month 2: Decided to use Supabase instead of custom auth
Month 3: Decided to use server actions for mutations
Month 4: Decided against using Prisma (use raw SQL instead)

Month 6: New tech lead asks: "What decisions were made and why?"
Team: "Check Slack? Search commit messages? Ask around?"

Impact: No single source of truth, tribal knowledge, onboarding friction.

Problem 4: Context Loss Across Team Changes

As team members leave and join, institutional memory degrades:

Year 1: 5 engineers, shared context
Year 2: 2 original engineers leave, 2 new engineers join
Year 3: 3 more original engineers leave, 3 new engineers join
Year 4: 0 original engineers remain

Institutional memory: GONE

Impact: Why decisions were made becomes unknown, patterns drift, quality degrades.

The Solution

Maintain learning files (*-learning.json) that capture decisions, rationales, and context in a structured format that LLMs can read and learn from.

What is a Learning File?

A learning file is a structured JSON document that tracks:

  1. Decisions made (approved or rejected)
  2. Rationale for each decision
  3. Context when decision was made
  4. Alternatives considered and why they were rejected
  5. Who proposed and who approved the decision
  6. When the decision was made

Core Concept

Before an LLM generates suggestions, it reads the learning file to understand past decisions:

┌─────────────────────────────────────────┐
│ LLM Scanner Workflow                    │
├─────────────────────────────────────────┤
│ 1. Read *-learning.json file            │ ← Load past decisions
│ 2. Understand approved/rejected items   │ ← Learn from history
│ 3. Analyze codebase for new issues      │ ← Find new problems
│ 4. Filter out already-rejected ideas    │ ← Prevent redundancy
│ 5. Propose only novel suggestions       │ ← High-signal output
│ 6. Reference past decisions in output   │ ← Show awareness
└─────────────────────────────────────────┘

Result: LLM learns from past decisions, stops re-proposing rejected ideas, and preserves institutional knowledge.

Implementation

Step 1: Define the Schema

Create a structured schema for learning files:

// learning-file.schema.ts
interface LearningFile {
  type: 'eslint-rules' | 'architecture' | 'dependencies' | 'patterns';
  lastUpdated: string; // ISO 8601 date
  decisions: Decision[];
}

interface Decision {
  id: string; // Unique identifier (e.g., rule name, pattern name)
  status: 'approved' | 'rejected' | 'deferred' | 'superseded';
  category: string; // e.g., 'best-practices', 'code-style', 'performance'
  priority: 'high' | 'medium' | 'low';
  
  decision: {
    rationale: string; // Why this decision was made
    context: string; // Relevant context at time of decision
    alternatives: string[]; // Other options considered
  };
  
  metadata: {
    proposedBy: string; // Who/what proposed it (e.g., "claude-eslint-scanner")
    decidedBy: string; // Who approved/rejected (e.g., "engineering-team")
    dateProposed: string; // ISO 8601 date
    dateDecided: string; // ISO 8601 date
    supersededBy?: string; // If replaced by another decision
  };
}

Step 2: Create Learning Files

Create learning files for different domains:

# Project structure
project/
├── eslint-rules-learning.json       # Linting decisions
├── architecture-decisions.json       # Architecture patterns
├── dependency-decisions.json         # Package choices
├── testing-patterns-learning.json    # Testing approaches
└── code-patterns-learning.json       # Code style decisions

Step 3: Example Learning File

eslint-rules-learning.json:

{
  "type": "eslint-rules",
  "lastUpdated": "2025-11-01",
  "decisions": [
    {
      "id": "no-console",
      "status": "approved",
      "category": "best-practices",
      "priority": "high",
      "decision": {
        "rationale": "Console statements bypass structured logging, making it hard to filter logs in production. All packages must use @repo/logger-config for consistent log formatting and levels.",
        "context": "We have a centralized logging system (Datadog) that requires structured JSON logs. Console.log outputs aren't captured properly.",
        "alternatives": [
          "Allow console in dev only (rejected - creates dev/prod parity issues)",
          "Use custom logger wrapper (rejected - reinventing wheel, @repo/logger-config already exists)"
        ]
      },
      "metadata": {
        "proposedBy": "claude-eslint-scanner",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-11-15",
        "dateDecided": "2024-11-20"
      }
    },
    {
      "id": "max-lines-per-function",
      "status": "rejected",
      "category": "code-style",
      "priority": "low",
      "decision": {
        "rationale": "Too opinionated and breaks valid long functions. Some integration tests legitimately exceed any reasonable limit due to setup/teardown requirements.",
        "context": "We have integration tests that set up complex scenarios (auth, database state, API mocking) that require 80-100 lines. Breaking these into smaller functions reduces readability.",
        "alternatives": [
          "Set limit to 100 lines (rejected - arbitrary, still breaks valid cases)",
          "Exclude test files (rejected - inconsistent, some prod code also valid to be long)",
          "Use code review instead (approved - human judgment better for this)"
        ]
      },
      "metadata": {
        "proposedBy": "claude-eslint-scanner",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-11-20",
        "dateDecided": "2024-11-22"
      }
    },
    {
      "id": "@typescript-eslint/explicit-function-return-type",
      "status": "approved",
      "category": "type-safety",
      "priority": "high",
      "decision": {
        "rationale": "Explicit return types prevent accidental type widening and make function contracts clear. LLMs generate better code when return types are explicit.",
        "context": "We've seen multiple bugs where inferred return types were wider than intended (e.g., function returns 'string | undefined' when we expected always 'string').",
        "alternatives": [
          "Rely on inference (rejected - caused bugs)",
          "Require only on exported functions (rejected - inconsistent)"
        ]
      },
      "metadata": {
        "proposedBy": "tech-lead",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-10-01",
        "dateDecided": "2024-10-05"
      }
    }
  ]
}

Step 4: Integrate with LLM Scanners

When running automated scanners, load the learning file first:

// eslint-scanner.ts
import { readFile } from 'fs/promises';
import Anthropic from '@anthropic-ai/sdk';

interface LearningFile {
  decisions: Decision[];
}

interface Decision {
  id: string;
  status: string;
  decision: {
    rationale: string;
    context: string;
  };
}

async function scanForESLintRules() {
  // Load past decisions
  const learningFile = JSON.parse(
    await readFile('eslint-rules-learning.json', 'utf-8')
  ) as LearningFile;
  
  // Create prompt with learning context
  const prompt = `
# Task
Analyze this codebase and suggest new ESLint rules.

# Past Decisions (DO NOT re-propose rejected rules)

${learningFile.decisions
  .filter(d => d.status === 'rejected')
  .map(d => `
## REJECTED: ${d.id}
- Rationale: ${d.decision.rationale}
- Context: ${d.decision.context}
`)
  .join('\n')}

# Approved Rules (reference these as examples of good suggestions)

${learningFile.decisions
  .filter(d => d.status === 'approved')
  .map(d => `
## APPROVED: ${d.id}
- Rationale: ${d.decision.rationale}
`)
  .join('\n')}

# Instructions

1. Analyze the codebase for potential ESLint rule opportunities
2. DO NOT suggest any rules marked REJECTED above
3. Only suggest rules that solve real problems in the codebase
4. For each suggestion, explain:
   - What problem it solves
   - Why it's different from rejected rules
   - Concrete examples from the codebase

Now analyze the codebase and suggest new rules.
  `;
  
  // Call LLM
  const client = new Anthropic();
  const response = await client.messages.create({
    model: 'claude-sonnet-4',
    max_tokens: 4096,
    messages: [{ role: 'user', content: prompt }],
  });
  
  return response.content;
}

Step 5: Update Learning File After Decisions

When a team makes a decision, update the learning file:

// update-learning-file.ts
import { readFile, writeFile } from 'fs/promises';

interface NewDecision {
  id: string;
  status: 'approved' | 'rejected';
  rationale: string;
  context: string;
  alternatives: string[];
  category: string;
  priority: 'high' | 'medium' | 'low';
}

async function addDecision(
  learningFilePath: string,
  decision: NewDecision,
  proposedBy: string,
  decidedBy: string
) {
  // Load existing file
  const content = await readFile(learningFilePath, 'utf-8');
  const learningFile = JSON.parse(content);
  
  // Add new decision
  learningFile.decisions.push({
    id: decision.id,
    status: decision.status,
    category: decision.category,
    priority: decision.priority,
    decision: {
      rationale: decision.rationale,
      context: decision.context,
      alternatives: decision.alternatives,
    },
    metadata: {
      proposedBy,
      decidedBy,
      dateProposed: new Date().toISOString().split('T')[0],
      dateDecided: new Date().toISOString().split('T')[0],
    },
  });
  
  // Update lastUpdated
  learningFile.lastUpdated = new Date().toISOString().split('T')[0];
  
  // Save
  await writeFile(
    learningFilePath,
    JSON.stringify(learningFile, null, 2),
    'utf-8'
  );
  
  console.log(`✅ Added decision: ${decision.id} (${decision.status})`);
}

// Usage
await addDecision(
  'eslint-rules-learning.json',
  {
    id: 'no-magic-numbers',
    status: 'approved',
    rationale: 'Magic numbers reduce code clarity and make refactoring harder',
    context: 'Found 47 instances of unexplained numeric literals in business logic',
    alternatives: [
      'Allow in test files (approved - tests can use magic numbers)',
    ],
    category: 'best-practices',
    priority: 'medium',
  },
  'claude-eslint-scanner',
  'engineering-team'
);

Real-World Example: Architecture Decisions

architecture-decisions.json

{
  "type": "architecture",
  "lastUpdated": "2025-11-01",
  "decisions": [
    {
      "id": "use-factory-pattern-over-classes",
      "status": "approved",
      "category": "design-patterns",
      "priority": "high",
      "decision": {
        "rationale": "Factory functions provide better dependency injection, avoid 'this' binding issues, enable better tree-shaking, and are easier to mock in tests. Classes add complexity without benefits for our use case.",
        "context": "Our codebase is primarily functional. We don't use inheritance or polymorphism. Services are small, focused functions. Classes would be ceremonial overhead.",
        "alternatives": [
          "Use classes for consistency with OOP (rejected - we're not doing OOP)",
          "Mix factories and classes (rejected - inconsistent, confusing)",
          "Use classes only for stateful services (rejected - we have no stateful services)"
        ]
      },
      "metadata": {
        "proposedBy": "senior-engineer-alex",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-03-10",
        "dateDecided": "2024-03-15"
      }
    },
    {
      "id": "use-trpc-over-rest",
      "status": "approved",
      "category": "api-design",
      "priority": "high",
      "decision": {
        "rationale": "tRPC provides end-to-end type safety from client to server, eliminating whole classes of bugs. No need to maintain separate API types or OpenAPI schemas. LLMs can understand tRPC routes better than REST endpoints.",
        "context": "We're building a Next.js monorepo with TypeScript. Both frontend and backend are in the same repo. Type safety across API boundaries is critical.",
        "alternatives": [
          "REST with OpenAPI codegen (rejected - separate schema maintenance burden)",
          "GraphQL (rejected - overkill for our use case, steeper learning curve)",
          "Server actions only (rejected - need explicit API layer for mobile apps)"
        ]
      },
      "metadata": {
        "proposedBy": "tech-lead-jordan",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-01-05",
        "dateDecided": "2024-01-12"
      }
    },
    {
      "id": "avoid-prisma-use-raw-sql",
      "status": "approved",
      "category": "database",
      "priority": "high",
      "decision": {
        "rationale": "Prisma's query builder abstracts away too much control. We need raw SQL for complex joins, CTEs, and performance optimization. Prisma migrations are also limiting for our schema complexity.",
        "context": "Our database schema has 80+ tables with complex relationships. We frequently use window functions, CTEs, and advanced PostgreSQL features that Prisma doesn't support well.",
        "alternatives": [
          "Use Prisma with raw SQL escape hatches (rejected - worst of both worlds)",
          "Use Kysely query builder (considered - may adopt later if raw SQL becomes unmanageable)",
          "Use Drizzle ORM (rejected - still too much abstraction)"
        ]
      },
      "metadata": {
        "proposedBy": "database-specialist-taylor",
        "decidedBy": "engineering-team",
        "dateProposed": "2024-04-20",
        "dateDecided": "2024-04-28"
      }
    }
  ]
}

Integration Patterns

Pattern 1: Pre-Scan Learning

Load learning file before scanning:

const learningContext = await loadLearningFile('eslint-rules-learning.json');
const scanResults = await scanCodebase(learningContext);

Pattern 2: Post-Decision Updates

Update learning file after team reviews:

const newRules = await reviewProposedRules(scanResults);
await updateLearningFile('eslint-rules-learning.json', newRules);

Pattern 3: Reference Past Decisions in Output

LLM output should reference learning file:

## Suggested Rule: no-implicit-any

**Problem**: Found 23 instances of implicit 'any' types

**Why this is different from rejected rules**:
- Unlike 'max-lines-per-function' (rejected for being too opinionated),
  this rule has clear correctness criteria: implicit 'any' is always a mistake
- Similar to approved rule 'explicit-function-return-type' which also
  enforces type explicitness

**Recommendation**: APPROVE (high priority)

Pattern 4: Periodic Learning File Review

Schedule quarterly reviews:

// In scheduled workflow
const oldDecisions = learningFile.decisions.filter(d => {
  const age = Date.now() - new Date(d.metadata.dateDecided).getTime();
  const ageInMonths = age / (1000 * 60 * 60 * 24 * 30);
  return ageInMonths > 12;
});

if (oldDecisions.length > 0) {
  console.log('These decisions are over 1 year old. Review if still valid:');
  oldDecisions.forEach(d => {
    console.log(`- ${d.id}: ${d.decision.rationale}`);
  });
}

Benefits

Benefit 1: No Redundant Suggestions

Before learning files:

Monthly scanner run: 20 suggestions
- 8 new ideas
- 12 already rejected (60% noise!)

Review time: 2 hours (reviewing same ideas repeatedly)

After learning files:

Monthly scanner run: 8 suggestions
- 8 new ideas
- 0 already rejected (0% noise!)

Review time: 30 minutes (only reviewing novel ideas)

Time saved: 1.5 hours per month = 18 hours/year

Benefit 2: Context Preservation

Scenario: Team member leaves

Before learning files:

Knowledge: Lost forever
New members: "Why did we decide this?"
Team: "No idea, that person left"
Result: Re-debate decisions, potential regressions

After learning files:

Knowledge: Preserved in learning file
New members: "Why did we decide this?"
Team: "Check architecture-decisions.json"
Result: Understand rationale, maintain consistency

Benefit 3: Audit Trail

Before learning files:

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course
Question: "When did we decide to use tRPC?"
Answer: "Uh... check commit history? Search Slack?"
Result: Hours of searching, might not find answer

After learning files:

Question: "When did we decide to use tRPC?"
Answer: "Check architecture-decisions.json"
Result: Instant answer with full context and rationale

Benefit 4: LLM Teaching

Before learning files:

LLM: "Have you considered using classes for services?"
Team: "We already decided against that!"
LLM: (next session) "Have you considered using classes?"
Result: LLM never learns project philosophy

After learning files:

LLM reads: "use-factory-pattern-over-classes: approved"
LLM: "I see you use factory functions. I'll suggest improvements to your existing factories."
Result: LLM aligns with project philosophy

Best Practices

1. Keep Rationales Detailed

// ❌ Bad: Vague rationale
{
  "rationale": "We prefer factories"
}

// ✅ Good: Detailed rationale
{
  "rationale": "Factory functions provide better dependency injection (can pass deps as params), avoid 'this' binding issues (no context confusion), enable better tree-shaking (no class prototype), and are easier to mock in tests (just pass different functions). Classes add complexity without benefits for our functional codebase."
}

2. Capture Alternatives Considered

// ❌ Bad: No alternatives
{
  "alternatives": []
}

// ✅ Good: Show what was considered
{
  "alternatives": [
    "Use classes for consistency with OOP (rejected - we're not doing OOP)",
    "Mix factories and classes (rejected - inconsistent)",
    "Use classes only for stateful services (rejected - no stateful services)"
  ]
}

3. Include Context

// ❌ Bad: No context
{
  "context": ""
}

// ✅ Good: Relevant context
{
  "context": "Our codebase is primarily functional. We don't use inheritance or polymorphism. Services are small, focused functions. Classes would be ceremonial overhead."
}

4. Review and Prune Periodically

// Mark outdated decisions as superseded
{
  "id": "use-webpack",
  "status": "superseded",
  "metadata": {
    "supersededBy": "use-vite",
    "supersededDate": "2024-06-01"
  }
}

5. Use Consistent Categories

Define standard categories:

const CATEGORIES = [
  'best-practices',
  'code-style',
  'performance',
  'security',
  'type-safety',
  'architecture',
  'testing',
  'documentation',
] as const;

6. Link to Related Decisions

{
  "id": "use-server-actions-for-mutations",
  "decision": {
    "rationale": "...",
    "relatedDecisions": [
      "use-trpc-over-rest (complements tRPC for queries)"
    ]
  }
}

Common Pitfalls

❌ Pitfall 1: Not Loading Learning File

Problem: LLM doesn’t read learning file before generating suggestions

Solution: Always load and inject into prompt

❌ Pitfall 2: Vague Rationales

Problem: “We don’t like this” doesn’t help future LLMs or team members

Solution: Explain why with concrete details

❌ Pitfall 3: Not Updating After Decisions

Problem: Learning file becomes stale, loses value

Solution: Make updating part of decision workflow

❌ Pitfall 4: Too Many Categories

Problem: 50 different categories makes file hard to navigate

Solution: Limit to 5-10 standard categories

❌ Pitfall 5: Not Versioning Learning Files

Problem: No history of how decisions evolved

Solution: Commit learning files to git

Integration with Other Patterns

Combine with Scheduled LLM Analysis

Use learning files in monthly scanners:

// In scheduled workflow
const learningFiles = await Promise.all([
  loadLearningFile('eslint-rules-learning.json'),
  loadLearningFile('architecture-decisions.json'),
  loadLearningFile('dependency-decisions.json'),
]);

const scanResults = await runMonthlyAnalysis(learningFiles);

Combine with Hierarchical CLAUDE.md

Reference learning files in CLAUDE.md:

# Architecture Decisions

All architectural decisions are documented in `architecture-decisions.json`.
Before proposing new patterns, read this file to understand our philosophy.

Key decisions:
- Factory pattern over classes (see architecture-decisions.json)
- tRPC over REST (see architecture-decisions.json)
- Raw SQL over Prisma (see architecture-decisions.json)

Combine with Repository Digest Reports

Include learning file summary in digests:

# Monthly Digest

## New Decisions This Month

- **Approved**: `no-magic-numbers` ESLint rule
- **Rejected**: `max-file-lines` (too opinionated)
- **Superseded**: Webpack → Vite (build tool upgrade)

Measuring Success

Metric 1: Redundancy Rate

Redundant suggestions = (Already rejected / Total suggestions) × 100

Target: <5% redundancy

Before learning files: 60% redundancy
After learning files: 2% redundancy

Metric 2: Review Time

Avg review time per suggestion:

Before learning files: 15 minutes (re-debating rejected ideas)
After learning files: 5 minutes (only novel ideas)

Time saved: 10 min/suggestion

Metric 3: Onboarding Time

Time to understand architectural decisions:

Before learning files: 2-4 weeks (tribal knowledge, asking around)
After learning files: 2-3 days (read learning files)

Time saved: 1.5-3.5 weeks per new hire

Metric 4: Decision Re-litigation

How often do we re-debate past decisions?

Before learning files: 2-3 times per quarter
After learning files: <1 time per year

Conclusion

Learning files transform stateless LLMs into learning systems that remember and respect past decisions.

Key Benefits:

  1. No redundant suggestions: LLMs stop re-proposing rejected ideas
  2. Preserved context: Rationales survive team changes
  3. Audit trail: Complete history of architectural decisions
  4. LLM teaching: AI learns project philosophy over time
  5. Faster onboarding: New members understand decisions instantly

Implementation Checklist:

  • Define learning file schema
  • Create learning files for each domain (linting, architecture, dependencies)
  • Update LLM scanners to load learning files before generating suggestions
  • Create workflow for updating learning files after decisions
  • Reference learning files in CLAUDE.md
  • Schedule periodic learning file reviews
  • Track redundancy rate and review time metrics

The result: Institutional memory that doesn’t degrade, AI that learns from your team’s decisions, and dramatically reduced time wasted on redundant discussions.

Related Concepts

References

Topics
Ai TeachingAutomationDecision TrackingInstitutional MemoryKnowledge RetentionLearning FilesLlm ContextMeta LearningScanner IntegrationTeam Knowledge

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix