Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Frontmatter handling in `bear-create-note`** — when the `text` parameter begins with a YAML frontmatter block (`---`…`---`), the tool assembles the note as a single text payload, bypassing Bear's own title/tag insertion so frontmatter stays at the top. Tags follow the configured convention: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter behave exactly as before.
- **Frontmatter-safe tag insertion in `bear-add-tag`** — instead of blindly prepending tags to the beginning of the note (which overwrites YAML frontmatter), the tool now detects frontmatter and rewrites the note with tags at the configured location: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter continue to use the original Bear tag insertion path.

## [2.12.0] - 2026-04-21

### Removed
Expand Down
24 changes: 24 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Progress: yaml-frontmatter-fix

## Status: Complete

## What was done

### Task 1: API Validation (FINDINGS.md)
- Fetched official Bear x-callback-url docs
- Confirmed `mode=replace_all` is a documented, supported value for `/add-text`
- Confirmed `/replace-note` does not exist (BRIEF.md was mistaken about this alternative)
- No code changes required

### Task 2: Commit split
- Commit 26fd648 (mixed bear-create-note + bear-add-tag changes) split into two atomic commits
- Note: TASK.md expected 3 commits from 26fd648, but the bear-urls.ts change was already in d86067b; the correct split yielded 2 commits from that mixed change
- Final branch structure: 8 commits on top of main, including this follow-up documentation refresh

### Task 3: Documentation refresh
- SUMMARY.md updated with new commit SHAs, API validation result, resolved blockers
- PROGRESS.md updated (this file)

## Remaining work (for PR author)
- Run system tests with Bear open: `npm run test:system`
- Manual verification: create a frontmatter note via MCP, add a tag, confirm structure in Bear
Comment on lines +1 to +24
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PROGRESS.md is tracking branch-specific execution status and references internal task artifacts (e.g., FINDINGS.md) that aren’t present in the repo. This kind of progress log tends to go stale quickly once merged; consider removing it from the repository root or relocating it to a more appropriate place (e.g., PR description or contributor docs) if you want to preserve the narrative.

Suggested change
# Progress: yaml-frontmatter-fix
## Status: Complete
## What was done
### Task 1: API Validation (FINDINGS.md)
- Fetched official Bear x-callback-url docs
- Confirmed `mode=replace_all` is a documented, supported value for `/add-text`
- Confirmed `/replace-note` does not exist (BRIEF.md was mistaken about this alternative)
- No code changes required
### Task 2: Commit split
- Commit 26fd648 (mixed bear-create-note + bear-add-tag changes) split into two atomic commits
- Note: TASK.md expected 3 commits from 26fd648, but the bear-urls.ts change was already in d86067b; the correct split yielded 2 commits from that mixed change
- Final branch structure: 8 commits on top of main, including this follow-up documentation refresh
### Task 3: Documentation refresh
- SUMMARY.md updated with new commit SHAs, API validation result, resolved blockers
- PROGRESS.md updated (this file)
## Remaining work (for PR author)
- Run system tests with Bear open: `npm run test:system`
- Manual verification: create a frontmatter note via MCP, add a tag, confirm structure in Bear
# Progress
This repository does not keep branch-specific progress logs in versioned documentation.
Short-lived implementation notes, task checklists, commit-splitting details, and manual verification steps should be recorded in the pull request description or other contributor workflow materials instead of this file.
Keep repository-root documentation focused on stable, long-term project information.

Copilot uses AI. Check for mistakes.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,33 @@ Example standalone configuration with the convention enabled:
}
```

### Frontmatter Handling

The server understands [YAML frontmatter](https://jekyllrb.com/docs/front-matter/) — a `---`…`---` block at the start of a note — and keeps it intact across create and tag operations.

**Creating a note with frontmatter** (`bear-create-note`): if the `text` parameter begins with a frontmatter block, the server assembles the final note itself before sending it to Bear. This prevents Bear from inserting the title or tags outside the frontmatter block. Tags follow the configured convention: by default they are placed at the end of the note; when `UI_ENABLE_NEW_NOTE_CONVENTION=true`, they are placed after the title.

```
# Input
text: "---\nstatus: draft\n---\nBody content."
title: "My Note"
tags: "project"

# Stored note
---
status: draft
---
# My Note
Body content.
#project
```

Notes without frontmatter behave exactly as before — no behavior change.

**Adding tags to a note with frontmatter** (`bear-add-tag`): instead of prepending tags at the very top of the note (which would overwrite the frontmatter), the tool rewrites the note so tags follow the configured convention: at the end by default, or after the title when `UI_ENABLE_NEW_NOTE_CONVENTION=true`. Notes without frontmatter use the original Bear tag insertion path.

Frontmatter is detected only when `---` is the very first line of the note text **and** a closing `---` exists on its own line later. A `---` horizontal rule in the note body is never mistaken for frontmatter.

### Content Replacement

Enable the `bear-replace-text` tool to replace content in existing notes — either the full note body or a specific section under a header.
Expand Down
79 changes: 79 additions & 0 deletions SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Branch Summary: yaml-frontmatter-fix

## What Changed

8 commits on top of `main` (26fd648 was split into two atomic commits; see FINDINGS.md for why the expected 3-way split became 2 commits from that mixed change):

| SHA | Description |
|-----|-------------|
| d86067b | feat(note-conventions): add parseFrontmatter and formatTagsAsInlineSyntax helpers; add replace_all to BearUrlParams.mode |
| 6b66bc0 | test(note-conventions): add unit tests for parseFrontmatter and formatTagsAsInlineSyntax |
| 88b6951 | feat(bear-create-note): auto-detect and preserve YAML frontmatter |
| a318830 | feat(bear-add-tag): preserve frontmatter when adding tags |
| 8878bf1 | test(system): add frontmatter integration tests for create-note and add-tag |
| 8bfc620 | docs: add Frontmatter handling section to README and update CHANGELOG |
| 0a21763 | chore: add SUMMARY.md / PROGRESS.md |
| a76da24 | chore: update progress and summary after API validation and commit split |

## Files Modified

- **`src/operations/note-conventions.ts`** — added `parseFrontmatter` and `formatTagsAsInlineSyntax`; refactored `applyNoteConventions` to use the extracted helper
- **`src/infra/bear-urls.ts`** — added `'replace_all'` to `BearUrlParams.mode` union type
- **`src/tools/note-tools.ts`** — Fix 1 (bear-create-note) and Fix 2 (bear-add-tag)
- **`src/operations/note-conventions.test.ts`** — 10 new unit tests for the two new helpers
- **`tests/system/frontmatter.test.ts`** — new integration test suite (4 tests, requires Bear)
- **`README.md`** — new "Frontmatter Handling" section under Configuration
- **`CHANGELOG.md`** — [Unreleased] entries for both fixes

## Fix 1: bear-create-note

When `text` starts with `---\n…\n---`, the handler now:
1. Parses the frontmatter block using `parseFrontmatter`
2. Assembles the note as `frontmatter → # title → #tag line → body` (joined with `\n`)
3. Passes the assembled string as `text` only — no separate `title` or `tags` URL params — so Bear uses the H1 as the title and does not insert anything outside the frontmatter block

Backward compat: if no frontmatter is detected, the original code path runs unchanged.

## Fix 2: bear-add-tag

When the existing note text (read from SQLite) starts with `---\n`, the handler:
1. Parses the frontmatter block
2. Builds a new full-note text: `frontmatter\n<tag line>\n<body>`
3. Writes it back using `add-text?mode=replace_all` (replaces entire note content)

When no frontmatter: original `mode=prepend` + `tags` URL param, unchanged.

## API Validation (Task 1)

`mode=replace_all` is **officially documented** in the Bear x-callback-url docs. The original concern in TASK.md was unfounded — no redesign is needed. See FINDINGS.md for the full verification.

The `/replace-note` endpoint mentioned in BRIEF.md as an alternative does not exist in the Bear API.

## Test Results

```
Unit tests: 47 passed / 0 failed (includes 10 new parseFrontmatter/formatTagsAsInlineSyntax tests)
Build: tsc clean, 0 errors
Integration: tests/system/frontmatter.test.ts — NOT run (requires Bear app running)
```

## Blockers / Open Questions

1. **ZTEXT storage format**: the implementation assumes that for notes created with frontmatter (via Fix 1), Bear stores ZTEXT starting with `---` (i.e., Bear does not prepend a `# Title` line automatically). This needs system test validation.

2. **Note title after replace_all**: the Bear docs don't specify whether `replace_all` updates ZTITLE from the first H1. If it doesn't, the SQLite title remains unchanged (which is fine — the title hasn't changed). Either way, the operation is safe.

## Suggested PR Description

### Summary
- `bear-create-note`: auto-detects YAML frontmatter (`---`…`---` at start of `text`) and assembles the note content so frontmatter, title (H1), and tags appear in the right order without Bear interfering
- `bear-add-tag`: detects frontmatter in the existing note text and inserts new tags immediately after the closing `---` instead of clobbering it with a blind prepend
- `mode=replace_all` is an officially documented Bear API parameter — verified against the Bear x-callback-url documentation
- Backward compatible: notes without frontmatter follow the exact same code paths as before

### Test plan
- [x] All 47 unit tests pass (`npm test`)
- [x] TypeScript build clean (`npm run build`)
- [ ] Run `npm run test:system` with Bear open to validate integration tests in `tests/system/frontmatter.test.ts`
- [ ] Manually verify a note created with frontmatter shows the correct structure in Bear
- [ ] Manually verify `bear-add-tag` on a frontmatter note places tags after the `---` block
Comment on lines +1 to +79
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUMMARY.md (and PROGRESS.md) contain branch-specific metadata (branch name, commit SHAs, status) that will become stale immediately after merge and add long-term maintenance noise to the repo root. Consider moving this content into the PR description/discussion (or docs/dev if it must be retained as project documentation) and removing these files from the shipped tree.

Suggested change
# Branch Summary: yaml-frontmatter-fix
## What Changed
8 commits on top of `main` (26fd648 was split into two atomic commits; see FINDINGS.md for why the expected 3-way split became 2 commits from that mixed change):
| SHA | Description |
|-----|-------------|
| d86067b | feat(note-conventions): add parseFrontmatter and formatTagsAsInlineSyntax helpers; add replace_all to BearUrlParams.mode |
| 6b66bc0 | test(note-conventions): add unit tests for parseFrontmatter and formatTagsAsInlineSyntax |
| 88b6951 | feat(bear-create-note): auto-detect and preserve YAML frontmatter |
| a318830 | feat(bear-add-tag): preserve frontmatter when adding tags |
| 8878bf1 | test(system): add frontmatter integration tests for create-note and add-tag |
| 8bfc620 | docs: add Frontmatter handling section to README and update CHANGELOG |
| 0a21763 | chore: add SUMMARY.md / PROGRESS.md |
| a76da24 | chore: update progress and summary after API validation and commit split |
## Files Modified
- **`src/operations/note-conventions.ts`** — added `parseFrontmatter` and `formatTagsAsInlineSyntax`; refactored `applyNoteConventions` to use the extracted helper
- **`src/infra/bear-urls.ts`** — added `'replace_all'` to `BearUrlParams.mode` union type
- **`src/tools/note-tools.ts`** — Fix 1 (bear-create-note) and Fix 2 (bear-add-tag)
- **`src/operations/note-conventions.test.ts`** — 10 new unit tests for the two new helpers
- **`tests/system/frontmatter.test.ts`** — new integration test suite (4 tests, requires Bear)
- **`README.md`** — new "Frontmatter Handling" section under Configuration
- **`CHANGELOG.md`**[Unreleased] entries for both fixes
## Fix 1: bear-create-note
When `text` starts with `---\n…\n---`, the handler now:
1. Parses the frontmatter block using `parseFrontmatter`
2. Assembles the note as `frontmatter → # title → #tag line → body` (joined with `\n`)
3. Passes the assembled string as `text` only — no separate `title` or `tags` URL params — so Bear uses the H1 as the title and does not insert anything outside the frontmatter block
Backward compat: if no frontmatter is detected, the original code path runs unchanged.
## Fix 2: bear-add-tag
When the existing note text (read from SQLite) starts with `---\n`, the handler:
1. Parses the frontmatter block
2. Builds a new full-note text: `frontmatter\n<tag line>\n<body>`
3. Writes it back using `add-text?mode=replace_all` (replaces entire note content)
When no frontmatter: original `mode=prepend` + `tags` URL param, unchanged.
## API Validation (Task 1)
`mode=replace_all` is **officially documented** in the Bear x-callback-url docs. The original concern in TASK.md was unfounded — no redesign is needed. See FINDINGS.md for the full verification.
The `/replace-note` endpoint mentioned in BRIEF.md as an alternative does not exist in the Bear API.
## Test Results
```
Unit tests: 47 passed / 0 failed (includes 10 new parseFrontmatter/formatTagsAsInlineSyntax tests)
Build: tsc clean, 0 errors
Integration: tests/system/frontmatter.test.ts — NOT run (requires Bear app running)
```
## Blockers / Open Questions
1. **ZTEXT storage format**: the implementation assumes that for notes created with frontmatter (via Fix 1), Bear stores ZTEXT starting with `---` (i.e., Bear does not prepend a `# Title` line automatically). This needs system test validation.
2. **Note title after replace_all**: the Bear docs don't specify whether `replace_all` updates ZTITLE from the first H1. If it doesn't, the SQLite title remains unchanged (which is fine — the title hasn't changed). Either way, the operation is safe.
## Suggested PR Description
### Summary
- `bear-create-note`: auto-detects YAML frontmatter (`---``---` at start of `text`) and assembles the note content so frontmatter, title (H1), and tags appear in the right order without Bear interfering
- `bear-add-tag`: detects frontmatter in the existing note text and inserts new tags immediately after the closing `---` instead of clobbering it with a blind prepend
- `mode=replace_all` is an officially documented Bear API parameter — verified against the Bear x-callback-url documentation
- Backward compatible: notes without frontmatter follow the exact same code paths as before
### Test plan
- [x] All 47 unit tests pass (`npm test`)
- [x] TypeScript build clean (`npm run build`)
- [ ] Run `npm run test:system` with Bear open to validate integration tests in `tests/system/frontmatter.test.ts`
- [ ] Manually verify a note created with frontmatter shows the correct structure in Bear
- [ ] Manually verify `bear-add-tag` on a frontmatter note places tags after the `---` block
# Summary
This repository does not retain branch-specific summaries, commit SHAs, PR status, or other short-lived review metadata in the shipped tree.
If implementation notes for a change need to be preserved:
- keep PR-specific context in the PR description or discussion
- move stable, long-lived technical documentation into `docs/dev` or another appropriate documentation file
See the relevant source files, tests, `README.md`, and `CHANGELOG.md` for durable project information.

Copilot uses AI. Check for mistakes.
2 changes: 1 addition & 1 deletion src/infra/bear-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface BearUrlParams {
tags?: string | undefined;
id?: string | undefined;
header?: string | undefined;
mode?: 'append' | 'prepend' | 'replace' | undefined;
mode?: 'append' | 'prepend' | 'replace' | 'replace_all' | undefined;
new_line?: 'yes' | 'no' | undefined;
file?: string | undefined;
filename?: string | undefined;
Expand Down
135 changes: 134 additions & 1 deletion src/operations/note-conventions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,66 @@
import { describe, expect, it } from 'vitest';

import { applyNoteConventions } from './note-conventions.js';
import {
applyNoteConventions,
formatTagsAsInlineSyntax,
insertInlineTags,
parseFrontmatter,
} from './note-conventions.js';

describe('parseFrontmatter', () => {
it('returns null frontmatter when text does not start with ---', () => {
const text = '# Title\nbody';
expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text });
});

it('detects frontmatter when --- is first line with closing ---', () => {
const result = parseFrontmatter('---\ntitle: Test\n---\nbody');
expect(result.frontmatter).toBe('---\ntitle: Test\n---');
expect(result.body).toBe('body');
});

it('returns null frontmatter when no closing --- exists', () => {
const text = '---\nno closing line\ncontent';
expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text });
});

it('ignores horizontal rules in body — --- not at line 1', () => {
const text = '# Title\n---\nhorizontal rule\n---\nbody';
expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text });
});

it('handles empty body after frontmatter', () => {
const result = parseFrontmatter('---\nkey: val\n---\n');
expect(result.frontmatter).toBe('---\nkey: val\n---');
expect(result.body).toBe('');
});

it('handles multi-key frontmatter with body including H1', () => {
const text = '---\ntitle: My Note\ntags: [work]\n---\n# My Note\ncontent';
const result = parseFrontmatter(text);
expect(result.frontmatter).toBe('---\ntitle: My Note\ntags: [work]\n---');
expect(result.body).toBe('# My Note\ncontent');
});

it('returns null frontmatter when --- is present but not at line 1', () => {
const text = '\n---\nkey: val\n---\nbody';
expect(parseFrontmatter(text)).toEqual({ frontmatter: null, body: text });
});
});

describe('formatTagsAsInlineSyntax', () => {
it('converts comma-separated tags to Bear inline syntax', () => {
expect(formatTagsAsInlineSyntax('work,urgent')).toBe('#work #urgent');
});

it('adds closing hash for tags with spaces', () => {
expect(formatTagsAsInlineSyntax('my tag')).toBe('#my tag#');
});

it('returns empty string for all-invalid tags', () => {
expect(formatTagsAsInlineSyntax('###,,,')).toBe('');
});
});

describe('applyNoteConventions', () => {
describe('pass-through when no tags provided', () => {
Expand Down Expand Up @@ -109,3 +169,76 @@ describe('applyNoteConventions', () => {
});
});
});

describe('insertInlineTags', () => {
it('appends tags at the end for default placement', () => {
const result = insertInlineTags('# Title\nBody', '#work', 'end');

expect(result).toBe('# Title\nBody\n#work');
});

it('inserts tags after the title without a separator by default', () => {
const result = insertInlineTags('# Title\nBody', '#work', 'after-title');

expect(result).toBe('# Title\n#work\nBody');
});

it('can insert tags after the title with a separator for new note creation', () => {
const result = insertInlineTags('# Title\nBody', '#work', 'after-title', {
separatorAfterTags: true,
});

expect(result).toBe('# Title\n#work\n---\nBody');
});

it('merges tags into an existing tag line after the title without adding a separator', () => {
const result = insertInlineTags('# Title\n#existing\nBody', '#work', 'after-title', {
separatorAfterTags: true,
});

expect(result).toBe('# Title\n#existing #work\nBody');
});

it('preserves an existing separator after an existing tag line', () => {
const result = insertInlineTags('# Title\n#existing\n---\nBody', '#work', 'after-title', {
separatorAfterTags: true,
});

expect(result).toBe('# Title\n#existing #work\n---\nBody');
});

it('does not insert tags before a title when body comes from frontmatter parsing', () => {
const parsed = parseFrontmatter('---\nstatus: draft\n---\n# Title\nBody');
const body = insertInlineTags(parsed.body, '#work', 'after-title');

expect(`${parsed.frontmatter}\n${body}`).toBe('---\nstatus: draft\n---\n# Title\n#work\nBody');
});

it('falls back to top-of-body placement without a separator when after-title has no H1', () => {
const result = insertInlineTags('Body without title', '#work', 'after-title');

expect(result).toBe('#work\nBody without title');
});

it('can include a separator in the no-H1 fallback for new note creation', () => {
const result = insertInlineTags('Body without title', '#work', 'after-title', {
separatorAfterTags: true,
});

expect(result).toBe('#work\n---\nBody without title');
});

it('merges with a leading tag line when after-title fallback has no H1', () => {
const result = insertInlineTags('#existing\nBody without title', '#work', 'after-title', {
separatorAfterTags: true,
});

expect(result).toBe('#existing #work\nBody without title');
});

it('omits separator when inserting after a title-only body', () => {
const result = insertInlineTags('# Title', '#work', 'after-title');

expect(result).toBe('# Title\n#work');
});
});
Loading