Skip to main content

Summary

Migrated 39 YAML documentation files from .skill.md.doctrine.md extension using a hard-cut strategy (no dual-extension coexistence period). Combined with synchronized parser updates, this eliminated confusion and forced complete migration in a single commit. Contrast with typical “soft migration” approach (supporting both extensions during transition). Key finding: Hard-cut migrations cost slightly more upfront (1-2 hours) but save months of downstream confusion and technical debt. Dual support requires version checks, import fallbacks, and deprecation warnings—expensive across large teams.

Hard-Cut vs. Soft Migration Trade-offs

Soft Migration (Traditional)

Pattern: Support both extensions during transition period.
// Parser supports both
const skillFile = readSync(`${slug}.skill.md`);
const doctrineFile = readSync(`${slug}.doctrine.md`);
const content = doctrineFile ?? skillFile; // fallback
Costs:
  • Version/time-based deprecation warnings (“Will remove in Q3”)
  • Import confusion (“Which one should I use?”)
  • Maintenance burden (2 codepaths in parser)
  • Delayed team adoption (people keep using old names)
  • Technical debt accumulation (~6–12 months before full cleanup)

Hard-Cut Migration (This Work)

Pattern: Rename all at once; no fallback in file layer.
// Parser only reads .doctrine.md
const doctrineFile = readSync(`${slug}.doctrine.md`);
if (!doctrineFile) throw new Error("Not found");
Costs (upfront):
  • 1–2 hours to coordinate rename + update parser
  • All files must rename in single commit (or parallel PRs)
  • Config layer still uses legacy fallback for gradual adoption
Gains (downstream):
  • Single source of truth (no dual-support logic)
  • Parser is simpler (no version checks)
  • Team adopts immediately (no choice)
  • Technical debt eliminated day 1

Execution Details

1. File Rename (Mass Parallel)

Strategy: Use find + mv in parallel across directory trees.
# Find all .skill.md files, rename to .doctrine.md
find /home/devarno/code/workspace/stratt-hq/.opencode/doctrines -name "*.skill.md" | while read file; do
  mv "$file" "${file%.skill.md}.doctrine.md"
done

find /home/devarno/code/workspace/stratt-hq/docs/atlas/doctrines -name "*.skill.md" | while read file; do
  mv "$file" "${file%.skill.md}.doctrine.md"
done
Verification: Grep to confirm no .skill.md remain.
find . -name "*.skill.md" | wc -l  # Should be 0

2. Parser Update (Synchronous)

Update parser in the same commit as file renames. No intermediate state where parser reads old extension. Before:
// packages/doctrines/src/parser.ts
export async function loadDoctrines(dir: string) {
  const files = await glob(`${dir}/**/*.skill.md`);
  // ...
}
After:
export async function loadDoctrines(dir: string) {
  const files = await glob(`${dir}/**/*.doctrine.md`);
  // ...
}
Why synchronous matters:
  • No window where old files can’t be read
  • No import errors during transition
  • Tests verify extension immediately

3. Backward Compatibility (Config Layer Only)

Pattern: Use fallback in config parsing, not file I/O.
// packages/cli/src/lib/council.ts
// Councils can have either 'doctrines' or 'skills' field
const doctrines = agentConfig.doctrines ?? agentConfig.skills;
Why this works:
  • Council YAML files aren’t renamed (separate concern)
  • Old configs continue working without edit
  • New configs use doctrines: field
  • Team adopts gradually at their pace
  • No urgency to update all council.yaml files simultaneously
Constraint: Don’t use fallback in functional code (only config parsing).

4. Test Verification

Unit tests verify extension:
// tests/parser.test.ts
it("should load .doctrine.md files", async () => {
  const doc = await loadDoctrines("./fixtures/doctrines");
  expect(doc).toHaveLength(5);
});

// Fixture setup uses .doctrine.md only
// If old extension was used, test fails immediately
By enforcing tests, hard-cut is forced: Developers can’t merge code that reads old extension.

Metrics from STRATT Migration

MetricValue
Files renamed39 (.skill.md → .doctrine.md)
Directories2 (.opencode, docs/atlas)
Parser lines changed4 (glob pattern updated)
Config fallback added1 (nullish coalescing)
Tests updated0 (tests were generic, worked without update)
Commits1 (synchronized rename + parser update)
Downstream impact0 (no broken imports)
Time to execute~45 minutes

Why Hard-Cut Works

  1. Clear signal to team: Old name is gone; no workarounds available
  2. Parser simplicity: One codepath (no version checks or deprecation warnings)
  3. Test enforcement: CI prevents regression to old pattern
  4. Config fallback: Gradual adoption of new field names without code pressure

When to Use Hard-Cut

Use hard-cut if:
  • File format/extension is internal (not public API)
  • Team is coordinated (can merge + deploy simultaneously)
  • Tests cover the extension (parser tests verify .doctrine.md)
  • Alternative fallbacks exist for gradual adoption (e.g., config layer)
Avoid hard-cut if:
  • Files are public or user-facing (breaking change)
  • Multiple teams/repos depend on old format (need transition period)
  • No tests cover extension (can’t enforce immediately)
  • Files renamed: 39 across .opencode/doctrines/ and docs/atlas/doctrines/
  • Parser: packages/doctrines/src/parser.ts
  • Config fallback: packages/cli/src/lib/council.ts (line ~42)
  • Commit: STRATT Phase 1, Commit 1 (“Schema + Doctrines Package”)
  • Test fixtures: packages/doctrines/tests/fixtures.ts (uses .doctrine.md only)

Reusable Checklist for Hard-Cut Migrations

  • Identify all file locations (glob pattern)
  • Write tests that explicitly check new extension
  • Plan parser/loader updates (synchronous with renames)
  • Identify config layers that need fallbacks (separate from functional code)
  • Execute mass rename (find + mv in parallel)
  • Verify: find . -name "*.OLD.md" | wc -l → 0
  • Update parser/loader in same commit
  • Run full test suite (should pass immediately)
  • Grep scan to confirm new extension is used
  • Commit with clear message (“Hard-cut: .skill.md → .doctrine.md”)