Compare commits

...

3 Commits

Author SHA1 Message Date
Test
89a605262c docs: require completion comment on Todoist task before /laputa-done (QA, refactoring, ADRs, code health) 2026-04-06 16:17:19 +02:00
Test
e097c5b717 docs: require pre-task code health check — refactor before feature work if below gate 2026-04-06 16:12:58 +02:00
Test
c0df2124ff fix: sync custom properties to VaultEntry on frontmatter changes
View filters that reference custom properties (e.g. "Trashed", "Priority")
were not reactively updating because contentToEntryPatch and
frontmatterToEntryPatch only synced known fields (type, status, etc.)
to the VaultEntry. Custom frontmatter keys were silently dropped.

Now frontmatterToEntryPatch produces a propertiesPatch for unknown keys,
and contentToEntryPatch includes them in the properties field. This
ensures evaluateView sees up-to-date custom property values when
re-filtering the note list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:10:02 +02:00
3 changed files with 60 additions and 27 deletions

View File

@@ -10,6 +10,8 @@
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
@@ -45,7 +47,15 @@ bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
- What was implemented (12 lines)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
---
@@ -61,7 +71,7 @@ After both phases pass, run `/laputa-done <task_id>` → moves to In Review, not
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests before adding new ones. Prefer E2E over unit tests for user flows.
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
### Code health (mandatory)
@@ -75,22 +85,15 @@ Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code
### Check suite (runs on every push)
```bash
pnpm lint && npx tsc --noEmit
pnpm test
pnpm test:coverage # frontend ≥70%
cargo test
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
pnpm lint && npx tsc --noEmit && pnpm test && pnpm test:coverage # frontend ≥70%
cargo test && cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
```
### Architecture Decision Records (ADRs)
### ADRs & docs
ADRs live in `docs/adr/`. Check before structural choices. Create the ADR **in the same commit as the code**. Never edit existing ADRs — create a new one that supersedes. Use `/create-adr` for template.
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
**When to create one:** new dependency, storage strategy, platform target, core abstraction change, cross-cutting pattern. **Not for:** bug fixes, UI styling, refactors, test additions.
### Keep docs/ in sync
After Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
After any Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
---
@@ -102,9 +105,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
### UI design
1. Open `ui-design.pen` first — study existing frames for visual language
2. Design in light mode. Create `design/<slug>.pen` for the task
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
### UI components — mandatory rules

View File

@@ -37,9 +37,14 @@ function extractWikilinks(value: FrontmatterValue): string[] {
*/
export type RelationshipPatch = Record<string, string[] | null>
/** Properties patch: a partial update to merge into `entry.properties`.
* Keys map to their new scalar values. A `null` value means "remove this key". */
export type PropertiesPatch = Record<string, string | number | boolean | null>
export interface EntryPatchResult {
patch: Partial<VaultEntry>
relationshipPatch: RelationshipPatch | null
propertiesPatch: PropertiesPatch | null
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -49,7 +54,8 @@ export function frontmatterToEntryPatch(
const k = key.toLowerCase().replace(/\s+/g, '_')
if (op === 'delete') {
const relPatch: RelationshipPatch = { [key]: null }
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
const propPatch: PropertiesPatch | null = !(k in ENTRY_DELETE_MAP) ? { [key]: null } : null
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch, propertiesPatch: propPatch }
}
const str = value != null ? String(value) : null
const arr = Array.isArray(value) ? value.map(String) : []
@@ -73,17 +79,24 @@ export function frontmatterToEntryPatch(
const wikilinks = value != null ? extractWikilinks(value) : []
const relationshipPatch: RelationshipPatch | null =
wikilinks.length > 0 ? { [key]: wikilinks } : null
return { patch: updates[k] ?? {}, relationshipPatch }
// For unknown keys (custom properties), produce a propertiesPatch
const isKnownKey = k in updates
const propertiesPatch: PropertiesPatch | null =
!isKnownKey && value != null ? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) } : null
return { patch: updates[k] ?? {}, relationshipPatch, propertiesPatch }
}
/** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */
export function contentToEntryPatch(content: string): Partial<VaultEntry> {
const fm = parseFrontmatter(content)
const merged: Partial<VaultEntry> = {}
const customProps: Record<string, string | number | boolean | null> = {}
for (const [key, value] of Object.entries(fm)) {
const { patch } = frontmatterToEntryPatch('update', key, value)
const { patch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
Object.assign(merged, patch)
if (propertiesPatch) Object.assign(customProps, propertiesPatch)
}
if (Object.keys(customProps).length > 0) merged.properties = customProps
return merged
}
@@ -117,6 +130,18 @@ export interface FrontmatterOpOptions {
silent?: boolean
}
/** Apply a properties patch by merging into the existing properties map. */
export function applyPropertiesPatch(
existing: Record<string, string | number | boolean | null>, propPatch: PropertiesPatch,
): Record<string, string | number | boolean | null> {
const merged = { ...existing }
for (const [k, v] of Object.entries(propPatch)) {
if (v === null) delete merged[k]
else merged[k] = v
}
return merged
}
/** Apply a relationship patch by merging into the existing relationships map. */
export function applyRelationshipPatch(
existing: Record<string, string[]>, relPatch: RelationshipPatch,
@@ -144,12 +169,17 @@ export async function runFrontmatterAndApply(
try {
const newContent = await executeFrontmatterOp(op, path, key, value)
callbacks.updateTab(path, newContent)
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
const { patch, relationshipPatch, propertiesPatch } = frontmatterToEntryPatch(op, key, value)
const fullPatch = { ...patch }
if (relationshipPatch && callbacks.getEntry) {
if ((relationshipPatch || propertiesPatch) && callbacks.getEntry) {
const current = callbacks.getEntry(path)
if (current) {
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
if (relationshipPatch) {
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
}
if (propertiesPatch) {
fullPatch.properties = applyPropertiesPatch(current.properties, propertiesPatch)
}
}
}
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)

View File

@@ -373,9 +373,10 @@ describe('frontmatterToEntryPatch', () => {
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
})
it('returns empty patch for unknown non-wikilink keys', () => {
it('returns propertiesPatch for unknown non-wikilink keys', () => {
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
expect(result.patch).toEqual({})
expect(result.propertiesPatch).toEqual({ custom_field: 'value' })
// Non-wikilink value → no relationship change
expect(result.relationshipPatch).toBeNull()
})
@@ -417,10 +418,11 @@ describe('frontmatterToEntryPatch', () => {
expect(result.patch).toEqual({ listPropertiesDisplay: [] })
})
it('returns empty patch for unknown key on delete, with relationship removal', () => {
it('returns empty patch for unknown key on delete, with relationship and properties removal', () => {
const result = frontmatterToEntryPatch('delete', 'unknown_key')
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ unknown_key: null })
expect(result.propertiesPatch).toEqual({ unknown_key: null })
})
it('delete of known key also produces relationship removal', () => {
@@ -490,9 +492,9 @@ describe('contentToEntryPatch', () => {
expect(contentToEntryPatch(content)).toEqual({ isA: 'Type', sidebarLabel: 'Projects' })
})
it('ignores unknown frontmatter keys', () => {
it('includes custom frontmatter keys in properties patch', () => {
const content = '---\ntype: Note\ncustom: value\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', properties: { custom: 'value' } })
})
it('preserves _favorite_index as a number (not null)', () => {