Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 980de2a424 fix(config): configDir honors $HOME over Bun's cached os.homedir()
The gbrainDir()->configDir() unification dropped preferences.ts's
process.env.HOME fallback; Bun caches homedir()'s initial value and
ignores later HOME mutations, so per-test HOME isolation broke and
test/migration-resume.test.ts leaked ledger entries across tests.
Restore the env-first fallback in the one shared place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:50:19 -07:00
a3864272a9 fix(sync): suppress last_sync_at when git pull failed (takeover of #1430)
Part A of the PR (no-op heartbeat) already shipped in v0.42.52.0; this lands
the still-missing part B rebased onto the refactored sync.ts: when the
upstream pull is attempted and fails (network partition, revoked credentials,
diverged remote), last_commit still advances for converged local imports, but
last_sync_at is not stamped — on the anchor writes, the full-reimport path,
AND the v0.42.52.0 up_to_date heartbeat (which otherwise still lied about
freshness after a failed pull). The heartbeat is also now gated on
--dry-run, which must stay side-effect free. Operator-skipped offline modes
(--no-pull, detached HEAD, no origin) are not failures and advance normally.

Co-authored-by: rayers <rayers@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:47:21 -07:00
efb73858dd fix(extract): don't split timeline bullets on bare hyphens (takeover of #1341)
The bullet separator regex treated any [—–-] with optional whitespace as the
Source/Summary split, so a hyphenated slug (acme-consulting-group) was split
mid-word into source "acme" on every extract pass. The plain hyphen now
requires surrounding whitespace; em/en dashes keep their old optional-space
behavior, so spaced-hyphen, unspaced-em-dash, long, and parenthesized sources
all keep extracting (the original PR's replacement dropped those).

Co-authored-by: Chapwi <Chapwi@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:44:14 -07:00
393369090b fix(lint): only unwrap whole-page code fences, not mid-document blocks (takeover of #1417)
Detection used /m regexes (a fence anywhere) while the fixer used
string-anchored regexes, so a note that merely CONTAINS a ```markdown block
lost only its closing fence and was re-corrupted every autopilot lint cycle.
Detection and fix now share a single analyzeMarkdownWrap check: a page is
wrapped only when the entire body (after frontmatter) is one fence.

Salvaged from PR #1417 with the unrelated plugin.json + perplexity-research
commits dropped (off-mission, reference a private external system).

Co-authored-by: trinh-macbook <trinh-macbook@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:43:11 -07:00
ed1563983f fix: Windows compat — CRLF frontmatter, posix relTarget, read-only apply-migrations, gbrainDir contract (takeover of #1294)
Four verified bugs from PR #1294, rebased onto master:
- skill-frontmatter/CRLF: frontmatter regex anchored on literal \n could not
  match CRLF-terminated SKILL.md files; normalize before matching. Shared
  extractFrontmatterBlock helper replaces three hand-rolled copies.
- skillpack bundle relTarget now posix.join'd (portable bundle key).
- apply-migrations: --list/--dry-run are read-only and now skip the
  schema-drift preflight DB connect entirely.
- preferences gbrainDir() now delegates to configDir() so GBRAIN_HOME means
  the same thing everywhere (parent dir + '.gbrain').

New on top of the PR: one-time legacy-path adoption — a brain that ran with
GBRAIN_HOME set had preferences.json + migrations/completed.jsonl one
directory above the new location; first access moves them so the migration
ledger is not orphaned by the contract unification.

Co-authored-by: xwang4-svg <xwang4-svg@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:42:53 -07:00
19 changed files with 489 additions and 99 deletions
+34 -25
View File
@@ -354,6 +354,40 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
if (cli.forceAll) return; // both surfaces flushed
}
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
// Bug 3 — surface wedged migrations as a loud, actionable error.
if (plan.wedged.length > 0) {
for (const m of plan.wedged) {
console.error(
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
` gbrain apply-migrations --force-retry ${m.version}\n` +
`Then re-run \`gbrain apply-migrations --yes\`.`,
);
}
// Don't exit — applied/partial/pending are still worth reporting and running.
}
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
}
// Read-only branches: --list and --dry-run print plan/state without
// touching anything. Skip the schema-drift pre-flight (which opens a DB
// connection) — its only purpose is to warn before APPLYING; for
// informational commands the connect+disconnect cycle is both wasted
// work and a portability hazard. On Windows the PGLite WASM teardown
// leaks worker handles in execFileSync contexts, surfacing as a spurious
// exit-1 to parent processes that shell out to `apply-migrations --list`
// (e.g. `skillpack-check`). The PGLite-specific skip below is preserved
// for non-read-only paths.
if (cli.list) { printList(plan, installed); process.exit(0); }
if (cli.dryRun) { printDryRun(plan, installed); process.exit(0); }
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
// apply-migrations runs orchestrator migrations only; schema migrations
// run via connectEngine() / initSchema(). Users often expect this CLI
@@ -392,31 +426,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
// run their filesystem-only phases.
}
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
// Bug 3 — surface wedged migrations as a loud, actionable error.
if (plan.wedged.length > 0) {
for (const m of plan.wedged) {
console.error(
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
` gbrain apply-migrations --force-retry ${m.version}\n` +
`Then re-run \`gbrain apply-migrations --yes\`.`,
);
}
// Don't exit — applied/partial/pending are still worth reporting and running.
}
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
}
if (cli.list) { printList(plan, installed); process.exit(0); }
if (cli.dryRun) { printDryRun(plan, installed); process.exit(0); }
const toRun: Migration[] = [...plan.partial, ...plan.pending];
if (toRun.length === 0) {
console.log('All migrations up to date.');
+5 -1
View File
@@ -474,7 +474,11 @@ export function extractTimelineFromContent(content: string, slug: string): Extra
const entries: ExtractedTimelineEntry[] = [];
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
// The separator is an em/en dash (optionally spaced, as before) OR a plain
// hyphen that MUST be surrounded by whitespace. A bare `-` with `\s*` on
// both sides split hyphenated slugs mid-word (`acme-consulting-group` →
// source "acme"), mangling timelines on every extract pass (#1341).
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)(?:\s*[—–]\s*|\s+-\s+)(.+)$/gm;
let match;
while ((match = bulletPattern.exec(content)) !== null) {
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
+51 -5
View File
@@ -68,6 +68,49 @@ const LLM_PREAMBLES = [
/^Absolutely\.?\s*Here[^.\n]*\.?\s*\n*/gim,
];
// ── Whole-page code-fence wrap (LLM artifact) ───────────────────────
//
// An LLM sometimes returns an ENTIRE page wrapped in a ```markdown fence.
// Strip that wrap — but ONLY when the whole body is wrapped, never when a note
// merely *contains* a ```markdown block mid-document (e.g. a Notion export that
// fences a config snippet). The earlier code detected the wrap with /m regexes
// (a fence anywhere) but stripped the closing fence with a string-anchored
// regex, so a mid-document block lost only its trailing ``` and was left with
// an open fence — re-corrupted on every autopilot lint cycle. Detection and fix
// now share this single check so they can't drift apart again.
function frontmatterLength(content: string): number {
const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(content);
return m ? m[0].length : 0;
}
/**
* Treat a page as fence-wrapped only when the entire body (after any YAML
* frontmatter) is a single ```markdown fence: the first non-blank body line
* opens it, the last non-blank body line closes it, and no bare ``` appears
* between them (which would mean the fence closed early — not a whole-page
* wrap). Returns the unwrapped content (frontmatter preserved) when wrapped.
*/
function analyzeMarkdownWrap(content: string): { wrapped: boolean; unwrapped: string } {
const fmLen = frontmatterLength(content);
const head = content.slice(0, fmLen);
const lines = content.slice(fmLen).split('\n');
let first = 0;
while (first < lines.length && lines[first].trim() === '') first++;
let last = lines.length - 1;
while (last >= 0 && lines[last].trim() === '') last--;
if (first >= last) return { wrapped: false, unwrapped: content };
if (!/^```(?:markdown|md)\s*$/.test(lines[first].trim())) return { wrapped: false, unwrapped: content };
if (lines[last].trim() !== '```') return { wrapped: false, unwrapped: content };
for (let i = first + 1; i < last; i++) {
if (lines[i].trim().startsWith('```')) return { wrapped: false, unwrapped: content };
}
return { wrapped: true, unwrapped: head + lines.slice(first + 1, last).join('\n') };
}
// ── Rules ──────────────────────────────────────────────────────────
/**
@@ -126,8 +169,10 @@ export function lintContent(content: string, filePath: string, opts: LintContent
}
}
// Rule: Wrapping code fences (```markdown ... ```)
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
// Rule: Wrapping code fences — the WHOLE page body wrapped in ```markdown
// ``` (an LLM artifact). A ```markdown block mid-document is legitimate and
// must NOT trigger this (see analyzeMarkdownWrap).
if (analyzeMarkdownWrap(content).wrapped) {
issues.push({
file: filePath, line: 1, rule: 'code-fence-wrap',
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
@@ -291,9 +336,10 @@ export function fixContent(content: string): string {
fixed = fixed.replace(pattern, '');
}
// Fix wrapping code fences
fixed = fixed.replace(/^```(?:markdown|md)\s*\n/, '');
fixed = fixed.replace(/\n```\s*$/, '');
// Fix wrapping code fences — only a genuine whole-page wrap (see
// analyzeMarkdownWrap); never strip the closing fence of a mid-note block.
const wrap = analyzeMarkdownWrap(fixed);
if (wrap.wrapped) fixed = wrap.unwrapped;
// Clean up excessive blank lines left by fixes
fixed = fixed.replace(/\n{3,}/g, '\n\n');
+4 -3
View File
@@ -29,6 +29,7 @@ import { runMigrateFence } from '../core/skillpack/migrate-fence.ts';
import { runScrubLegacy } from '../core/skillpack/scrub-legacy.ts';
import { runHarvest, HarvestError } from '../core/skillpack/harvest.ts';
import { autoDetectSkillsDir } from '../core/repo-root.ts';
import { extractFrontmatterBlock } from '../core/markdown.ts';
import {
RemoteSourceError,
classifySpec,
@@ -222,9 +223,9 @@ async function cmdList(args: string[]): Promise<void> {
let description: string | null = null;
if (existsSync(skillMd)) {
const body = readFileSync(skillMd, 'utf-8');
const fm = body.match(/^---\n([\s\S]*?)\n---/);
if (fm) {
const descMatch = fm[1].match(/^description:\s*["']?([^\n"']+)/m);
const fm = extractFrontmatterBlock(body);
if (fm !== null) {
const descMatch = fm.match(/^description:\s*["']?([^\n"']+)/m);
if (descMatch) description = descMatch[1].trim();
}
}
+33 -11
View File
@@ -1245,22 +1245,31 @@ async function writeSyncAnchor(
// git-intrinsic committer time of the HEAD we just synced). `undefined` keeps
// the legacy 2-column write; `null` clears the column (git unavailable).
newestContentEpochMs?: number | null,
// #1430: when the upstream pull was attempted and FAILED this run, advance
// last_commit (the local import still converged) but do NOT stamp
// last_sync_at — doctor's sync_freshness must not read the source as
// "fresh" when we never observed remote state (network partition, revoked
// credentials, diverged remote). Operator-skipped offline modes (--no-pull,
// detached HEAD, no origin) are NOT failures and pass false.
pullFailed = false,
): Promise<void> {
if (sourceId) {
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
// last_sync_at bookmarked on every last_commit advance.
// last_sync_at bookmarked on every last_commit advance — unless the pull
// failed this run (#1430).
if (which === 'last_commit') {
const syncAt = pullFailed ? '' : ', last_sync_at = now()';
if (newestContentEpochMs !== undefined) {
const iso = newestContentEpochMs === null
? null
: new Date(newestContentEpochMs).toISOString();
await engine.executeRaw(
`UPDATE sources SET last_commit = $1, last_sync_at = now(), newest_content_at = $3 WHERE id = $2`,
`UPDATE sources SET last_commit = $1${syncAt}, newest_content_at = $3 WHERE id = $2`,
[value, sourceId, iso],
);
} else {
await engine.executeRaw(
`UPDATE sources SET last_commit = $1, last_sync_at = now() WHERE id = $2`,
`UPDATE sources SET last_commit = $1${syncAt} WHERE id = $2`,
[value, sourceId],
);
}
@@ -1948,6 +1957,12 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
});
}
// #1430: distinguishes a true pull FAILURE (network/credentials/diverged)
// from operator-skipped offline modes. Only a real failure suppresses the
// last_sync_at freshness stamp on the anchor writes below; the pull_timeout
// path early-returns a partial before any anchor write.
let pullAttemptedAndFailed = false;
if (!opts.noPull && !detachedHead && originRemotePresent) {
const _t0 = Date.now();
serr(`[gbrain phase] sync.git_pull start`);
@@ -1995,6 +2010,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
serr(`Warning: git pull failed: ${msg.slice(0, 100)}`);
}
pullAttemptedAndFailed = true; // #1430: suppress last_sync_at below
}
}
@@ -2077,7 +2093,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// back to the authoritative full reconcile (which now also purges stale
// pages for deleted files; see performFullSync's delete-reconcile pass).
serr(`Sync anchor ${lastCommit.slice(0, 8)} object missing (gc'd after history rewrite). Running full reimport.`);
return performFullSync(engine, fullSyncRoots, headCommit, opts);
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
}
// Observability only — NOT control flow. A non-ancestor bookmark is still
@@ -2100,7 +2116,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// First sync
if (!lastCommit) {
return performFullSync(engine, fullSyncRoots, headCommit, opts);
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
}
// v0.42.x (#1794): resumable incremental sync — resolve the PINNED target.
@@ -2170,7 +2186,10 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// reads it), separate from the import-converged bookmark. Without this,
// a cron-driven `*/15 sync` over a quiet vault leaves last_sync_at pinned
// to the last real commit, so doctor falsely flags the source as stale.
if (opts.sourceId) {
// #1430: suppress the heartbeat when the pull was attempted and FAILED —
// we never observed remote state, so stamping freshness would mask the
// failure. `!opts.dryRun`: a preview must never write.
if (opts.sourceId && !pullAttemptedAndFailed && !opts.dryRun) {
await engine.executeRaw(
`UPDATE sources SET last_sync_at = now() WHERE id = $1`,
[opts.sourceId],
@@ -2192,7 +2211,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
`[sync] chunker_version gate: stored=${storedVersion ?? 'unset'}, current=${currentVersion}. ` +
`Forcing full re-chunk pass (git HEAD unchanged but pipeline version advanced).`,
);
const result = await performFullSync(engine, fullSyncRoots, headCommit, opts);
const result = await performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
await writeChunkerVersion(engine, opts.sourceId, currentVersion);
return result;
}
@@ -2221,7 +2240,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
`[sync] delta ${lastCommit.slice(0, 8)}..${pin.slice(0, 8)} unavailable ` +
`(${delta.reason}) — falling back to full reconcile.`,
);
return performFullSync(engine, fullSyncRoots, headCommit, opts);
return performFullSync(engine, fullSyncRoots, headCommit, opts, pullAttemptedAndFailed);
}
const manifest = delta.manifest;
@@ -2352,7 +2371,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// (#1794): advance to the PINNED target, and clear any checkpoint (a resume
// whose remaining range turned out to have no syncable changes still
// completes cleanly here).
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), pullAttemptedAndFailed);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
await clearOpCheckpoint(engine, ckpt.paths);
@@ -3175,7 +3194,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// "fresh". The checkpoint rows clear here — CONVERGENCE CONTRACT: sync
// convergence == IMPORT convergence; downstream extract/facts/embed is
// decoupled (its own resumable stale sweeps).
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), pullAttemptedAndFailed);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
@@ -3413,6 +3432,9 @@ async function performFullSync(
roots: { gitContextRoot: string; syncScopeRoot: string; anchorPath: string },
headCommit: string,
opts: SyncOpts,
// #1430: a full reimport triggered after a failed pull still must not mark
// the source "fresh" — threaded into the last_commit anchor write.
pullFailed = false,
): Promise<SyncResult> {
const { gitContextRoot, syncScopeRoot, anchorPath } = roots;
// Scoped sync → slugs/source_path are git-root-relative (matches the
@@ -3500,7 +3522,7 @@ async function performFullSync(
const advanceFull = async (): Promise<void> => {
// Persist sync state so the next sync is incremental. Routed through
// writeSyncAnchor so --source pins the right sources row.
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot), pullFailed);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
+3 -3
View File
@@ -12,6 +12,7 @@
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join, relative } from 'path';
import { extractFrontmatterBlock } from './markdown.ts';
import { findResolverFile, findAllResolverFiles, RESOLVER_FILENAMES_LABEL } from './resolver-filenames.ts';
import { loadOrDeriveManifest } from './skill-manifest.ts';
import {
@@ -219,9 +220,8 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
/** Simple YAML frontmatter parser — extracts triggers array if present. */
function extractTriggers(skillContent: string): string[] {
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return [];
const fm = fmMatch[1];
const fm = extractFrontmatterBlock(skillContent);
if (fm === null) return [];
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
if (!triggersMatch) return [];
return triggersMatch[1]
+4 -1
View File
@@ -1096,7 +1096,10 @@ export function configDir(): string {
}
return join(trimmed, '.gbrain');
}
return join(homedir(), '.gbrain');
// Prefer $HOME over os.homedir(): Bun caches homedir()'s initial value and
// ignores later process.env.HOME mutations, which breaks test isolation and
// any workflow that runs against a specific $HOME (CI, scripted installs).
return join(process.env.HOME || homedir(), '.gbrain');
}
export function configPath(): string {
+18
View File
@@ -3,6 +3,24 @@ import { safeLoad as yamlSafeLoad } from 'js-yaml';
import type { Page, PageType } from './types.ts';
import { slugifyPath } from './sync.ts';
/**
* Extract the raw YAML frontmatter body between leading `---` fences.
*
* Returns the body string (LF-normalized) or `null` when no frontmatter is
* present. Tolerant of CRLF line endings input is normalized before
* matching so the same call works on files authored on Windows.
*
* Use this when you only need to run a few targeted regexes against the
* frontmatter (e.g. extracting `name:` or `triggers:`); prefer
* `parseMarkdown` when you need the full body + timeline split + gray-matter
* YAML parsing, and `parseSkillFrontmatter` for SKILL.md-specific fields.
*/
export function extractFrontmatterBlock(content: string): string | null {
const normalized = content.replace(/\r\n/g, '\n');
const match = normalized.match(/^---\n([\s\S]*?)\n---/);
return match ? match[1] : null;
}
export type ParseValidationCode =
| 'MISSING_OPEN'
| 'MISSING_CLOSE'
+43 -32
View File
@@ -9,40 +9,22 @@
*/
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdtempSync, rmSync, existsSync, mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
function home(): string {
// `os.homedir()` in Bun caches its initial value and ignores later
// `process.env.HOME` mutations, which breaks test isolation and any
// workflow that needs to run against a specific $HOME (CI, scripted installs).
// Prefer the env var; fall back to the cached OS value. Matches the existing
// `src/commands/upgrade.ts` pattern.
//
// NOTE: prefsDir() and migrationsDir() route through gbrainPath() (which
// honors GBRAIN_HOME), so this fallback is only used by code paths that
// want $HOME directly (none in this file as of v0.30.3).
return process.env.HOME || homedir();
}
import { join, dirname } from 'path';
import { configDir } from './config.ts';
/**
* GBRAIN_HOME-aware override for the .gbrain directory. When the env var
* is set, this returns it directly (so the directory is GBRAIN_HOME itself,
* matching the convention `src/core/config.ts:gbrainPath` enforces).
* When unset, falls back to `<home>/.gbrain` so legacy callers and the
* doctor's filesystem-only checks keep working.
*
* Without this, `~/.gbrain/migrations/completed.jsonl` is the only path
* doctor reads on filesystem checks the test isolation contract that
* `gbrainPath()` provides for everywhere else doesn't extend here.
* GBRAIN_HOME-aware override for the .gbrain directory. Delegates to
* `configDir()` so the GBRAIN_HOME contract is defined in exactly one
* place pre-this-fix, this helper diverged from `configDir()` by
* returning `GBRAIN_HOME` directly instead of `GBRAIN_HOME/.gbrain`,
* which meant `~/.gbrain/migrations/completed.jsonl` (read by doctor)
* and `~/.gbrain/config.json` (read by loadConfig) lived in different
* directories whenever a test or operator set GBRAIN_HOME. The
* documented convention is "GBRAIN_HOME is a parent dir; we append
* `.gbrain`" see configDir() docstring in src/core/config.ts.
*/
function gbrainDir(): string {
const override = process.env.GBRAIN_HOME;
if (override) {
const trimmed = override.trim();
if (trimmed) return trimmed;
}
return join(home(), '.gbrain');
return configDir();
}
export type MinionMode = 'always' | 'pain_triggered' | 'off';
@@ -84,9 +66,38 @@ const VALID_MODES: ReadonlyArray<MinionMode> = ['always', 'pain_triggered', 'off
// `$HOME/.gbrain` directly, which leaked the developer's local migration
// ledger into E2E tests and CI runs even when GBRAIN_HOME was set.
function prefsDir(): string { return gbrainDir(); }
function prefsPath(): string { return join(prefsDir(), 'preferences.json'); }
function prefsPath(): string {
const p = join(prefsDir(), 'preferences.json');
adoptLegacyFile(p, 'preferences.json');
return p;
}
function migrationsDir(): string { return join(gbrainDir(), 'migrations'); }
function completedJsonlPath(): string { return join(migrationsDir(), 'completed.jsonl'); }
function completedJsonlPath(): string {
const p = join(migrationsDir(), 'completed.jsonl');
adoptLegacyFile(p, 'migrations', 'completed.jsonl');
return p;
}
/**
* One-time legacy-path adoption. Before the gbrainDir()configDir()
* unification, this module wrote preferences.json + migrations/completed.jsonl
* to `$GBRAIN_HOME/...` directly (no `.gbrain` suffix) whenever GBRAIN_HOME
* was set. Silently switching the read path would orphan an existing
* preferences file and worse an existing migration ledger, making every
* completed migration look pending again. So on first access after upgrade,
* move the legacy file into the new location. Best-effort: a failed move
* falls back to a fresh file (the pre-fix behavior for the new path).
*/
function adoptLegacyFile(newPath: string, ...legacySegments: string[]): void {
const override = process.env.GBRAIN_HOME?.trim();
if (!override) return; // GBRAIN_HOME unset → legacy and new paths were identical
const legacyPath = join(override, ...legacySegments);
if (legacyPath === newPath || existsSync(newPath) || !existsSync(legacyPath)) return;
try {
mkdirSync(dirname(newPath), { recursive: true });
renameSync(legacyPath, newPath);
} catch { /* best-effort */ }
}
/** Validate that a value is a recognized minion mode. Throws with the allowed list. */
export function validateMinionMode(value: unknown): asserts value is MinionMode {
+7 -1
View File
@@ -82,7 +82,13 @@ export interface ParsedFrontmatter {
* `readFileSync(path, 'utf-8')` at the boundary.
*/
export function parseSkillFrontmatter(content: string): ParsedFrontmatter | null {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
// Normalize CRLF → LF before matching. The literal `\n` after the opening
// `---` fence cannot match `\r`, so a Windows-authored SKILL.md (CRLF)
// would fall through with raw=='' and silently lose every parsed field.
// Normalizing here also gives downstream value parsers an LF-only body so
// `[^"'\n]+?` character classes don't accidentally swallow a trailing `\r`.
const normalized = content.replace(/\r\n/g, '\n');
const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return null;
const raw = fmMatch[1];
const out: ParsedFrontmatter = { raw };
+3 -3
View File
@@ -27,6 +27,7 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { extractFrontmatterBlock } from './markdown.ts';
export interface ManifestEntry {
name: string;
@@ -47,9 +48,8 @@ export interface ManifestLoadResult {
function parseSkillName(skillMdPath: string): string | null {
try {
const content = readFileSync(skillMdPath, 'utf-8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return null;
const fm = fmMatch[1];
const fm = extractFrontmatterBlock(content);
if (fm === null) return null;
// Match `name: foo` or `name: "foo"` or `name: 'foo'`
const nameMatch = fm.match(/^name:\s*["']?([^"'\n]+?)["']?\s*$/m);
if (!nameMatch) return null;
+6 -3
View File
@@ -8,7 +8,7 @@
*/
import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
import { join, dirname, isAbsolute, resolve } from 'path';
import { join, dirname, isAbsolute, resolve, posix } from 'path';
import { parseMarkdown } from '../markdown.ts';
@@ -142,9 +142,12 @@ function walkFiles(absDir: string, prefix: string, out: BundleEntry[], sharedDep
continue;
}
if (stat.isDirectory()) {
walkFiles(abs, join(prefix, e), out, sharedDep);
// relTarget is a portable bundle path; always use forward-slash
// joining so Windows installs produce the same manifest shape as
// Linux/macOS (callers downstream string-match on `alpha/SKILL.md`).
walkFiles(abs, posix.join(prefix, e), out, sharedDep);
} else if (stat.isFile()) {
out.push({ source: abs, relTarget: join(prefix, e), sharedDep });
out.push({ source: abs, relTarget: posix.join(prefix, e), sharedDep });
}
}
}
+32
View File
@@ -410,6 +410,38 @@ describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
});
});
// ---------------------------------------------------------------------------
// CRLF tolerance — Windows-authored SKILL.md must not cause mece_gap
// false-positives. The frontmatter parser used to anchor on literal `\n`
// after the leading `---`, which failed silently on `\r\n`-terminated files
// and made every skill on Windows look like it was missing a triggers: array.
// ---------------------------------------------------------------------------
describe("checkResolvable — CRLF line endings", () => {
let dir: string;
afterEachCleanup(() => rmSync(dir, { recursive: true, force: true }));
test("does not flag mece_gap for skills with CRLF-terminated frontmatter", () => {
const lfBody = `---\nname: alpha\ndescription: alpha skill\ntriggers:\n - "alpha trigger"\n---\n\n# Alpha\n`;
dir = mkdtempSync(join(tmpdir(), "gbrain-crlf-"));
writeFileSync(
join(dir, "RESOLVER.md"),
`## Test\n| Trigger | Skill |\n|-----|-----|\n| "alpha trigger" | \`skills/alpha/SKILL.md\` |\n`,
);
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({ skills: [{ name: "alpha", path: "alpha/SKILL.md" }] }, null, 2),
);
mkdirSync(join(dir, "alpha"), { recursive: true });
// Convert the otherwise-valid SKILL.md to CRLF — this is the actual
// on-disk shape produced by editors on Windows.
writeFileSync(join(dir, "alpha", "SKILL.md"), lfBody.replace(/\n/g, "\r\n"));
const report = checkResolvable(dir);
const gaps = report.issues.filter(i => i.type === "mece_gap");
expect(gaps).toEqual([]);
});
});
// bun:test has no beforeEach/afterEach at module scope cleanly interacting
// with closures; a small helper keeps cleanup readable and per-test.
function afterEachCleanup(fn: () => void) {
+44
View File
@@ -136,6 +136,50 @@ describe('extractTimelineFromContent', () => {
expect(entries).toHaveLength(1);
});
it('does not split a hyphenated word in the bullet body (bare-hyphen regression, #1341)', () => {
// A hyphen inside a slug/compound must not be read as the source/summary
// separator. Pre-fix this produced source "acme".
const content = `- **2025-05-13** | acme-consulting-group kickoff call`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries.find((e) => e.source === 'acme')).toBeUndefined();
});
it('keeps hyphenated words intact in the summary when splitting on a spaced dash', () => {
const content = `- **2025-05-13** | Call — acme-consulting-group renewed`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('Call');
expect(entries[0].summary).toBe('acme-consulting-group renewed');
});
it('still splits on a spaced plain hyphen separator', () => {
// Bullets written as `Source - Summary` extracted fine pre-fix and must
// keep extracting (the #1341 PR's em/en-dash-only regex dropped them).
const content = `- **2025-05-13** | Standup - shipped the beta`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('Standup');
expect(entries[0].summary).toBe('shipped the beta');
});
it('still splits on an unspaced em dash separator', () => {
const content = `- **2025-05-13** | Call—renewed the contract`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('Call');
expect(entries[0].summary).toBe('renewed the contract');
});
it('long and parenthesized sources still extract', () => {
// The #1341 PR capped sources at 24 bracket-free chars, silently dropping
// previously-extracted bullets. No such cap here.
const content = `- **2025-05-13** | Quarterly business review (with acme-example team) — agreed on roadmap`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('Quarterly business review (with acme-example team)');
expect(entries[0].summary).toBe('agreed on roadmap');
});
it('extracts inline citation format entries', () => {
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
+19
View File
@@ -32,6 +32,14 @@ describe('lintContent', () => {
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(true);
});
test('does not flag a mid-document markdown code block as a page wrap', () => {
// Regression: a note that merely CONTAINS a ```markdown block (e.g. a
// Notion export fencing a config snippet) is not a whole-page wrap.
const content = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n';
const issues = lintContent(content, 'test.md');
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(false);
});
test('detects placeholder dates', () => {
const content = '---\ntitle: Test\ntype: person\ncreated: YYYY-MM-DD\n---\n\n# Test';
const issues = lintContent(content, 'test.md');
@@ -97,6 +105,17 @@ describe('fixContent', () => {
expect(fixed).toContain('# Title');
});
test('leaves a mid-document markdown code block intact (keeps its closing fence)', () => {
// Regression for the autopilot churn: fixContent used to strip only the
// trailing ```, leaving the fence open and the note re-corrupted each cycle.
const input = '---\ntitle: Notes\ntype: note\ncreated: 2026-05-25\n---\n\nIntro paragraph.\n\n```markdown\nKEY=value\nPORT=3002\n```\n';
const fixed = fixContent(input);
const fenceLines = (fixed.match(/^```/gm) ?? []).length;
expect(fenceLines).toBe(2);
expect(fixed).toContain('```markdown');
expect(fixed.trimEnd().endsWith('```')).toBe(true);
});
test('cleans up excessive blank lines after fix', () => {
const input = 'Of course. Here is the brain page.\n\n\n\n# Title\n\nContent.';
const fixed = fixContent(input);
+42 -5
View File
@@ -21,12 +21,15 @@ beforeEach(() => {
origHome = process.env.HOME;
origGbrainHome = process.env.GBRAIN_HOME;
tmp = mkdtempSync(join(tmpdir(), 'gbrain-prefs-test-'));
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
// is unset. Test fixtures write to `$tmp/.gbrain/...`, so set HOME only
// and clear GBRAIN_HOME — setting GBRAIN_HOME would route prefs to $tmp
// directly (no .gbrain suffix), which doesn't match the fixture layout.
// Both gbrainDir() (preferences) and configDir() (config) now share the
// same contract via configDir(): GBRAIN_HOME is the parent dir, and they
// always append `.gbrain`. So setting GBRAIN_HOME=$tmp routes prefs to
// `$tmp/.gbrain/...`, matching the fixture layout used in this file.
//
// HOME alone is unreliable for isolation: os.homedir() reads USERPROFILE
// on Windows, ignoring HOME. GBRAIN_HOME is the platform-neutral hook.
process.env.HOME = tmp;
delete process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmp;
});
afterEach(() => {
@@ -178,6 +181,40 @@ describe('appendCompletedMigration', () => {
});
});
describe('legacy GBRAIN_HOME path adoption', () => {
// Before gbrainDir() delegated to configDir(), a set GBRAIN_HOME meant
// preferences.json + migrations/completed.jsonl lived at $GBRAIN_HOME/...
// directly (no `.gbrain` suffix). The unification must not orphan those
// files — first access moves them to $GBRAIN_HOME/.gbrain/... .
test('adopts a legacy preferences.json on load', () => {
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }));
expect(loadPreferences()).toEqual({ minion_mode: 'off' });
expect(existsSync(join(tmp, '.gbrain', 'preferences.json'))).toBe(true);
expect(existsSync(join(tmp, 'preferences.json'))).toBe(false);
});
test('adopts a legacy migrations/completed.jsonl on load', () => {
mkdirSync(join(tmp, 'migrations'), { recursive: true });
writeFileSync(
join(tmp, 'migrations', 'completed.jsonl'),
JSON.stringify({ version: '0.21.0', status: 'complete' }) + '\n',
);
const entries = loadCompletedMigrations();
expect(entries.length).toBe(1);
expect(entries[0].version).toBe('0.21.0');
expect(existsSync(join(tmp, '.gbrain', 'migrations', 'completed.jsonl'))).toBe(true);
});
test('new-path file wins over a stale legacy file', () => {
mkdirSync(join(tmp, '.gbrain'), { recursive: true });
writeFileSync(join(tmp, '.gbrain', 'preferences.json'), JSON.stringify({ minion_mode: 'always' }));
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }));
expect(loadPreferences()).toEqual({ minion_mode: 'always' });
// Legacy file left untouched — never clobber the adopted copy.
expect(existsSync(join(tmp, 'preferences.json'))).toBe(true);
});
});
describe('loadCompletedMigrations', () => {
test('returns empty when file is missing', () => {
expect(loadCompletedMigrations()).toEqual([]);
+16 -6
View File
@@ -26,7 +26,11 @@ let tmp: string;
let origHome: string | undefined;
function run(args: string[]): { exitCode: number; stdout: string; stderr: string } {
const env = { ...process.env, HOME: tmp } as Record<string, string | undefined>;
// HOME alone doesn't redirect gbrain config on Windows: os.homedir() reads
// USERPROFILE on Win32. GBRAIN_HOME is the platform-neutral override that
// configDir() honors uniformly — set both so the test fixture isolates
// from the real user's `.gbrain/` directory on every platform.
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
try {
@@ -56,6 +60,12 @@ afterEach(() => {
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
});
// Each test below spawns `bun run cli.ts skillpack-check`, which itself
// spawns child `doctor` + `apply-migrations --list` processes. On Windows
// subprocess cold-start is ~600ms each; the default 5s bun:test timeout
// is too tight for the chained spawns. 30s leaves headroom for slower CI.
const SUBPROCESS_TIMEOUT = 30_000;
describe('gbrain skillpack-check', () => {
test('healthy fresh install → exit 0, healthy:true, empty actions', () => {
const result = run(['skillpack-check']);
@@ -66,7 +76,7 @@ describe('gbrain skillpack-check', () => {
expect(report.summary).toBe('gbrain skillpack healthy');
expect(report.version).toBeTruthy();
expect(report.ts).toBeTruthy();
});
}, SUBPROCESS_TIMEOUT);
test('half-migrated (partial completed.jsonl) → exit 1, apply-migrations in actions', () => {
const migrationsDir = join(tmp, '.gbrain', 'migrations');
@@ -88,7 +98,7 @@ describe('gbrain skillpack-check', () => {
const minions = doctorChecks.find(c => c.name === 'minions_migration');
expect(minions).toBeDefined();
expect(minions!.status).toBe('fail');
});
}, SUBPROCESS_TIMEOUT);
test('--quiet → no stdout, same exit code', () => {
// Healthy path quiet
@@ -106,7 +116,7 @@ describe('gbrain skillpack-check', () => {
const broken = run(['skillpack-check', '--quiet']);
expect(broken.exitCode).toBe(1);
expect(broken.stdout).toBe('');
});
}, SUBPROCESS_TIMEOUT);
test('--help → exit 0, prints usage', () => {
const result = run(['skillpack-check', '--help']);
@@ -114,7 +124,7 @@ describe('gbrain skillpack-check', () => {
expect(result.stdout).toContain('skillpack-check');
expect(result.stdout).toContain('healthy');
expect(result.stdout).toContain('Exit codes');
});
}, SUBPROCESS_TIMEOUT);
test('summary includes top action when multiple present', () => {
// Partial record creates apply-migrations action + the migrations count
@@ -130,5 +140,5 @@ describe('gbrain skillpack-check', () => {
const report = JSON.parse(result.stdout);
expect(report.summary).toMatch(/\d+ action\(s\)/);
expect(report.summary).toContain(report.actions[0]);
});
}, SUBPROCESS_TIMEOUT);
});
+11
View File
@@ -175,6 +175,17 @@ describe('enumerateBundle (D-CX-10 dependency closure)', () => {
expect(targets.some(t => t.startsWith('alpha/'))).toBe(true);
expect(targets.some(t => t.startsWith('beta/'))).toBe(true);
});
it('emits forward-slash relTarget paths on every platform', () => {
// relTarget is a portable bundle key consumed by string-matching
// callers (managed-block writer, install-plan diffs); Windows-style
// backslashes silently break them. This test fails fast if anyone
// re-introduces `path.join` for relTarget construction.
const { gbrainRoot } = scratchGbrain();
const m = loadBundleManifest(gbrainRoot);
const entries = enumerateBundle({ gbrainRoot, manifest: m });
const offending = entries.filter(e => e.relTarget.includes('\\'));
expect(offending).toEqual([]);
});
});
describe('buildManagedBlock + updateManagedBlock', () => {
+114
View File
@@ -1004,3 +1004,117 @@ describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant
expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit);
});
});
describe('#1430: failed git pull suppresses last_sync_at freshness', () => {
let engine: PGLiteEngine;
const repos: string[] = [];
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (repos.length) {
const d = repos.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
function mkRepo(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pullfail-'));
repos.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
const BASE_OPTS = { noEmbed: true, noExtract: true, sourceId: 'default' } as const;
async function sourceRow(): Promise<{ last_sync_at: string | null; last_commit: string | null }> {
const rows = await engine.executeRaw<{ last_sync_at: string | null; last_commit: string | null }>(
`SELECT last_sync_at, last_commit FROM sources WHERE id = 'default'`,
);
return rows[0] ?? { last_sync_at: null, last_commit: null };
}
test('up_to_date sync with a failed pull does NOT advance last_sync_at', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
// Origin points nowhere so a pull is ATTEMPTED and FAILS (non-timeout).
execSync('git remote add origin /nonexistent/repo.git', { cwd: repo, stdio: 'pipe' });
// Seed with --no-pull so the first sync succeeds and stamps freshness.
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
const before = await sourceRow();
expect(before.last_sync_at).not.toBeNull();
await new Promise((r) => setTimeout(r, 1100));
// Pull attempted (no noPull) → fails → up_to_date heartbeat suppressed.
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS });
expect(result.status).toBe('up_to_date');
const after = await sourceRow();
expect(after.last_sync_at).toEqual(before.last_sync_at);
});
test('incremental sync with a failed pull advances last_commit but NOT last_sync_at', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
execSync('git remote add origin /nonexistent/repo.git', { cwd: repo, stdio: 'pipe' });
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
const before = await sourceRow();
expect(before.last_sync_at).not.toBeNull();
// Local commit so the next sync takes the incremental import path.
writeFileSync(join(repo, 'people/bob.md'), personMd('Bob', 'Bob is a person.'));
execSync('git add -A && git commit -m "add bob"', { cwd: repo, stdio: 'pipe' });
const newHead = execSync('git rev-parse HEAD', { cwd: repo, encoding: 'utf-8' }).trim();
await new Promise((r) => setTimeout(r, 1100));
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS });
expect(result.status).toBe('synced');
const after = await sourceRow();
// Local import converged: bookmark advances. Remote never observed:
// freshness does not.
expect(after.last_commit).toBe(newHead);
expect(after.last_sync_at).toEqual(before.last_sync_at);
});
test('dry-run on an up-to-date source does NOT advance last_sync_at', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true });
const before = await sourceRow();
expect(before.last_sync_at).not.toBeNull();
await new Promise((r) => setTimeout(r, 1100));
const result = await performSync(engine, { repoPath: repo, ...BASE_OPTS, noPull: true, dryRun: true });
expect(result.status).toBe('up_to_date');
const after = await sourceRow();
expect(after.last_sync_at).toEqual(before.last_sync_at);
});
});