feat: GNAP sync engine for git-native task persistence (#396)

Implements Phase 1 of GNAP (Git-Native Agent Protocol) integration:
- SQLite stays primary, GNAP repo is an optional sync target
- Push task create/update/delete to .gnap/tasks/{id}.json with git commits
- Status/priority mapping between MC and GNAP formats
- Management API at /api/gnap (status, init, manual sync)
- GNAP badge in task board header with click-to-sync
- 15 unit tests covering mapping, init, push, remove, pull, and status

Enable with GNAP_ENABLED=true in .env. Follows the same fire-and-forget
pattern as the existing GitHub sync engine.

Closes #374
Supersedes #389
This commit is contained in:
nyk 2026-03-16 12:07:39 +07:00 committed by GitHub
parent d87307a4f8
commit 85b4184aa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 676 additions and 0 deletions

70
src/app/api/gnap/route.ts Normal file
View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { config } from '@/lib/config'
import { logger } from '@/lib/logger'
import {
initGnapRepo,
syncGnap,
getGnapStatus,
} from '@/lib/gnap-sync'
/**
* GET /api/gnap GNAP sync status
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const gnapConfig = config.gnap
if (!gnapConfig.enabled) {
return NextResponse.json({ enabled: false })
}
try {
const status = getGnapStatus(gnapConfig.repoPath)
return NextResponse.json({
enabled: true,
repoPath: gnapConfig.repoPath,
autoSync: gnapConfig.autoSync,
...status,
})
} catch (err) {
logger.error({ err }, 'GET /api/gnap error')
return NextResponse.json({ error: 'Failed to get GNAP status' }, { status: 500 })
}
}
/**
* POST /api/gnap?action=init|sync GNAP management
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const gnapConfig = config.gnap
if (!gnapConfig.enabled) {
return NextResponse.json({ error: 'GNAP is not enabled' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
try {
switch (action) {
case 'init': {
initGnapRepo(gnapConfig.repoPath)
const status = getGnapStatus(gnapConfig.repoPath)
return NextResponse.json({ success: true, ...status })
}
case 'sync': {
const result = syncGnap(gnapConfig.repoPath)
return NextResponse.json({ success: true, ...result })
}
default:
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 })
}
} catch (err) {
logger.error({ err, action }, 'POST /api/gnap error')
return NextResponse.json({ error: 'GNAP operation failed' }, { status: 500 })
}
}

View File

@ -8,6 +8,8 @@ import { validateBody, updateTaskSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
import { pushTaskToGitHub } from '@/lib/github-sync-engine';
import { pushTaskToGnap, removeTaskFromGnap } from '@/lib/gnap-sync';
import { config } from '@/lib/config';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -402,6 +404,12 @@ export async function PUT(
}
}
// Fire-and-forget GNAP sync for task updates
if (config.gnap.enabled && config.gnap.autoSync && changes.length > 0) {
try { pushTaskToGnap(updatedTask as any, config.gnap.repoPath) }
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task update') }
}
// Broadcast to SSE clients
eventBus.broadcast('task.updated', parsedTask);
@ -463,6 +471,12 @@ export async function DELETE(
workspaceId
);
// Remove from GNAP repo
if (config.gnap.enabled && config.gnap.autoSync) {
try { removeTaskFromGnap(taskId, config.gnap.repoPath) }
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task deletion') }
}
// Broadcast to SSE clients
eventBus.broadcast('task.deleted', { id: taskId, title: task.title });

View File

@ -8,6 +8,8 @@ import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/li
import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskCreateStatus } from '@/lib/task-status';
import { pushTaskToGitHub } from '@/lib/github-sync-engine';
import { pushTaskToGnap } from '@/lib/gnap-sync';
import { config } from '@/lib/config';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -314,6 +316,12 @@ export async function POST(request: NextRequest) {
}
}
// Fire-and-forget GNAP sync for new tasks
if (config.gnap.enabled && config.gnap.autoSync) {
try { pushTaskToGnap(parsedTask as any, config.gnap.repoPath) }
catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for new task') }
}
// Broadcast to SSE clients
eventBus.broadcast('task.created', parsedTask);

View File

@ -323,6 +323,8 @@ export function TaskBoardPanel() {
timeoutSeconds: 300
})
const [isSpawning, setIsSpawning] = useState(false)
const [gnapStatus, setGnapStatus] = useState<{ enabled: boolean; taskCount?: number; lastSync?: string } | null>(null)
const [gnapSyncing, setGnapSyncing] = useState(false)
const isLocal = dashboardMode === 'local'
const dragCounter = useRef(0)
const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
@ -412,6 +414,26 @@ export function TaskBoardPanel() {
fetchData()
}, [fetchData])
// Fetch GNAP status
useEffect(() => {
fetch('/api/gnap')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setGnapStatus(data) })
.catch(() => {})
}, [])
const handleGnapSync = useCallback(async () => {
setGnapSyncing(true)
try {
const res = await fetch('/api/gnap?action=sync', { method: 'POST' })
if (res.ok) {
const data = await res.json()
setGnapStatus(prev => prev ? { ...prev, taskCount: data.pushed, lastSync: data.lastSync } : prev)
}
} catch { /* ignore */ }
finally { setGnapSyncing(false) }
}, [])
// Sync global activeProject into local projectFilter
useEffect(() => {
const newFilter = activeProject ? String(activeProject.id) : 'all'
@ -663,6 +685,24 @@ export function TaskBoardPanel() {
<div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2>
{gnapStatus?.enabled && (
<button
onClick={handleGnapSync}
disabled={gnapSyncing}
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
title={gnapStatus.lastSync ? `Last sync: ${gnapStatus.lastSync}` : 'Click to sync'}
>
GNAP
{gnapStatus.taskCount != null && (
<span className="text-emerald-400/70">{gnapStatus.taskCount}</span>
)}
{gnapSyncing && (
<svg className="w-3 h-3 animate-spin" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M8 1.5a6.5 6.5 0 1 1-4.5 2" />
</svg>
)}
</button>
)}
<div className="relative">
<select
value={projectFilter}

View File

@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { execFileSync } from 'node:child_process'
import {
mcStatusToGnap,
gnapStatusToMc,
mcPriorityToGnap,
initGnapRepo,
pushTaskToGnap,
removeTaskFromGnap,
pullTasksFromGnap,
getGnapStatus,
type McTask,
} from '../gnap-sync'
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gnap-test-'))
})
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('status mapping', () => {
it('maps MC statuses to GNAP states', () => {
expect(mcStatusToGnap('pending')).toBe('backlog')
expect(mcStatusToGnap('inbox')).toBe('backlog')
expect(mcStatusToGnap('in_progress')).toBe('in_progress')
expect(mcStatusToGnap('done')).toBe('done')
expect(mcStatusToGnap('review')).toBe('review')
expect(mcStatusToGnap('blocked')).toBe('blocked')
expect(mcStatusToGnap('cancelled')).toBe('cancelled')
})
it('maps GNAP states back to MC statuses', () => {
expect(gnapStatusToMc('backlog')).toBe('inbox')
expect(gnapStatusToMc('in_progress')).toBe('in_progress')
expect(gnapStatusToMc('done')).toBe('done')
expect(gnapStatusToMc('review')).toBe('review')
})
it('falls back for unknown values', () => {
expect(mcStatusToGnap('unknown_status')).toBe('backlog')
expect(gnapStatusToMc('unknown_state')).toBe('inbox')
})
})
describe('priority mapping', () => {
it('maps MC priorities to GNAP priorities', () => {
expect(mcPriorityToGnap('low')).toBe('low')
expect(mcPriorityToGnap('medium')).toBe('medium')
expect(mcPriorityToGnap('high')).toBe('high')
expect(mcPriorityToGnap('critical')).toBe('critical')
expect(mcPriorityToGnap('urgent')).toBe('critical')
})
it('falls back to medium for unknown priorities', () => {
expect(mcPriorityToGnap('unknown')).toBe('medium')
})
})
describe('initGnapRepo', () => {
it('creates directory structure and initializes git', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
expect(fs.existsSync(path.join(repoPath, 'version'))).toBe(true)
expect(fs.existsSync(path.join(repoPath, 'agents.json'))).toBe(true)
expect(fs.existsSync(path.join(repoPath, 'tasks'))).toBe(true)
expect(fs.existsSync(path.join(repoPath, '.git'))).toBe(true)
expect(fs.readFileSync(path.join(repoPath, 'version'), 'utf-8').trim()).toBe('1')
})
it('is idempotent — re-running does not error', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
initGnapRepo(repoPath)
expect(fs.existsSync(path.join(repoPath, '.git'))).toBe(true)
})
})
describe('pushTaskToGnap', () => {
it('writes task JSON and commits', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
const task: McTask = {
id: 42,
title: 'Test task',
description: 'A test',
status: 'in_progress',
priority: 'high',
assigned_to: 'agent-claude',
tags: ['auth', 'sprint-1'],
created_at: 1710500000,
updated_at: 1710510000,
project_id: 1,
}
pushTaskToGnap(task, repoPath)
const filePath = path.join(repoPath, 'tasks', 'mc-42.json')
expect(fs.existsSync(filePath)).toBe(true)
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
expect(content.id).toBe('mc-42')
expect(content.title).toBe('Test task')
expect(content.state).toBe('in_progress')
expect(content.priority).toBe('high')
expect(content.assignee).toBe('agent-claude')
expect(content.tags).toEqual(['auth', 'sprint-1'])
expect(content.mc_id).toBe(42)
expect(content.mc_project_id).toBe(1)
// Verify it was committed
const log = execFileSync('git', ['log', '--oneline'], {
cwd: repoPath,
encoding: 'utf-8',
})
expect(log).toContain('Update task mc-42')
})
it('handles string tags (JSON serialized)', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
const task: McTask = {
id: 1,
title: 'String tags task',
status: 'pending',
priority: 'low',
tags: '["bug","fix"]',
}
pushTaskToGnap(task, repoPath)
const content = JSON.parse(
fs.readFileSync(path.join(repoPath, 'tasks', 'mc-1.json'), 'utf-8')
)
expect(content.tags).toEqual(['bug', 'fix'])
})
})
describe('removeTaskFromGnap', () => {
it('removes the task file and commits', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
const task: McTask = {
id: 7,
title: 'To be removed',
status: 'done',
priority: 'low',
}
pushTaskToGnap(task, repoPath)
expect(fs.existsSync(path.join(repoPath, 'tasks', 'mc-7.json'))).toBe(true)
removeTaskFromGnap(7, repoPath)
expect(fs.existsSync(path.join(repoPath, 'tasks', 'mc-7.json'))).toBe(false)
const log = execFileSync('git', ['log', '--oneline'], {
cwd: repoPath,
encoding: 'utf-8',
})
expect(log).toContain('Remove task mc-7')
})
it('does nothing when task does not exist', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
// Should not throw
removeTaskFromGnap(999, repoPath)
})
})
describe('pullTasksFromGnap', () => {
it('reads all task files from the repo', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
pushTaskToGnap({ id: 1, title: 'Task A', status: 'pending', priority: 'low' }, repoPath)
pushTaskToGnap({ id: 2, title: 'Task B', status: 'done', priority: 'high' }, repoPath)
const tasks = pullTasksFromGnap(repoPath)
expect(tasks).toHaveLength(2)
const ids = tasks.map(t => t.id).sort()
expect(ids).toEqual(['mc-1', 'mc-2'])
})
it('returns empty array for non-existent directory', () => {
const tasks = pullTasksFromGnap(path.join(tmpDir, 'nonexistent'))
expect(tasks).toEqual([])
})
})
describe('getGnapStatus', () => {
it('reports uninitialized for empty directory', () => {
const status = getGnapStatus(path.join(tmpDir, 'empty'))
expect(status.initialized).toBe(false)
expect(status.taskCount).toBe(0)
expect(status.hasRemote).toBe(false)
})
it('reports correct status after init and push', () => {
const repoPath = path.join(tmpDir, 'gnap-repo')
initGnapRepo(repoPath)
pushTaskToGnap({ id: 1, title: 'Task', status: 'pending', priority: 'medium' }, repoPath)
const status = getGnapStatus(repoPath)
expect(status.initialized).toBe(true)
expect(status.taskCount).toBe(1)
expect(status.hasRemote).toBe(false)
expect(status.remoteUrl).toBe('')
})
})

View File

@ -63,6 +63,9 @@ const defaultMemoryDir = (() => {
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
})()
const resolvedGnapRepoPath =
process.env.GNAP_REPO_PATH || path.join(configuredDataDir, '.gnap')
export const config = {
claudeHome:
process.env.MC_CLAUDE_HOME ||
@ -91,6 +94,12 @@ export const config = {
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
homeDir: os.homedir(),
gnap: {
enabled: process.env.GNAP_ENABLED === 'true',
repoPath: resolvedGnapRepoPath,
autoSync: process.env.GNAP_AUTO_SYNC !== 'false',
remoteUrl: process.env.GNAP_REMOTE_URL || '',
},
// Data retention (days). 0 = keep forever. Negative values are clamped to 0.
retention: {
activities: clampInt(Number(process.env.MC_RETAIN_ACTIVITIES_DAYS || '90'), 0, 3650, 90),

313
src/lib/gnap-sync.ts Normal file
View File

@ -0,0 +1,313 @@
/**
* GNAP Sync Engine push MC tasks to a Git-Native Agent Protocol repo.
*
* SQLite remains the primary store. The GNAP repo is an optional sync target
* following the same pattern as `github-sync-engine.ts`.
*
* Phase 1: MC GNAP only (push). Pull/bidirectional sync is Phase 2.
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { logger } from '@/lib/logger'
// ── Status / priority mapping ──────────────────────────────────
const MC_TO_GNAP_STATUS: Record<string, string> = {
pending: 'backlog',
inbox: 'backlog',
assigned: 'ready',
ready: 'ready',
in_progress: 'in_progress',
review: 'review',
quality_review: 'review',
completed: 'done',
done: 'done',
blocked: 'blocked',
cancelled: 'cancelled',
}
const GNAP_TO_MC_STATUS: Record<string, string> = {
backlog: 'inbox',
ready: 'assigned',
in_progress: 'in_progress',
review: 'review',
done: 'done',
blocked: 'blocked',
cancelled: 'cancelled',
}
const MC_TO_GNAP_PRIORITY: Record<string, string> = {
low: 'low',
medium: 'medium',
high: 'high',
critical: 'critical',
urgent: 'critical',
}
export function mcStatusToGnap(status: string): string {
return MC_TO_GNAP_STATUS[status] || 'backlog'
}
export function gnapStatusToMc(state: string): string {
return GNAP_TO_MC_STATUS[state] || 'inbox'
}
export function mcPriorityToGnap(priority: string): string {
return MC_TO_GNAP_PRIORITY[priority] || 'medium'
}
// ── GNAP task JSON type ────────────────────────────────────────
export interface GnapTask {
id: string
title: string
description: string
state: string
assignee: string
priority: string
tags: string[]
created: string
updated: string
mc_id: number
mc_project_id: number | null
}
// ── Git helpers ────────────────────────────────────────────────
function git(repoPath: string, args: string[]): string {
try {
return execFileSync('git', args, {
cwd: repoPath,
encoding: 'utf-8',
timeout: 15_000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
} catch (err: any) {
const stderr = err.stderr?.toString?.() || ''
throw new Error(`git ${args[0]} failed: ${stderr || err.message}`)
}
}
function hasRemote(repoPath: string): boolean {
try {
const remotes = git(repoPath, ['remote'])
return remotes.length > 0
} catch {
return false
}
}
function hasChanges(repoPath: string): boolean {
try {
const status = git(repoPath, ['status', '--porcelain'])
return status.length > 0
} catch {
return false
}
}
// ── Core functions ─────────────────────────────────────────────
export function initGnapRepo(repoPath: string): void {
fs.mkdirSync(path.join(repoPath, 'tasks'), { recursive: true })
const versionFile = path.join(repoPath, 'version')
if (!fs.existsSync(versionFile)) {
fs.writeFileSync(versionFile, '1\n')
}
const agentsFile = path.join(repoPath, 'agents.json')
if (!fs.existsSync(agentsFile)) {
fs.writeFileSync(agentsFile, JSON.stringify({ agents: [] }, null, 2) + '\n')
}
// Init git if not already a repo
const gitDir = path.join(repoPath, '.git')
if (!fs.existsSync(gitDir)) {
git(repoPath, ['init'])
git(repoPath, ['add', '.'])
git(repoPath, ['commit', '-m', 'Initialize GNAP repository'])
}
logger.info({ repoPath }, 'GNAP repo initialized')
}
export interface McTask {
id: number
title: string
description?: string | null
status: string
priority: string
assigned_to?: string | null
tags?: string[] | string | null
created_at?: number | null
updated_at?: number | null
project_id?: number | null
}
function taskToGnapJson(task: McTask): GnapTask {
const tags = Array.isArray(task.tags)
? task.tags
: (typeof task.tags === 'string' ? JSON.parse(task.tags || '[]') : [])
return {
id: `mc-${task.id}`,
title: task.title,
description: task.description || '',
state: mcStatusToGnap(task.status),
assignee: task.assigned_to || '',
priority: mcPriorityToGnap(task.priority),
tags,
created: task.created_at
? new Date(task.created_at * 1000).toISOString()
: new Date().toISOString(),
updated: task.updated_at
? new Date(task.updated_at * 1000).toISOString()
: new Date().toISOString(),
mc_id: task.id,
mc_project_id: task.project_id ?? null,
}
}
export function pushTaskToGnap(task: McTask, repoPath: string): void {
const tasksDir = path.join(repoPath, 'tasks')
fs.mkdirSync(tasksDir, { recursive: true })
const gnapTask = taskToGnapJson(task)
const filePath = path.join(tasksDir, `${gnapTask.id}.json`)
fs.writeFileSync(filePath, JSON.stringify(gnapTask, null, 2) + '\n')
git(repoPath, ['add', path.relative(repoPath, filePath)])
if (hasChanges(repoPath)) {
git(repoPath, ['commit', '-m', `Update task ${gnapTask.id}: ${task.title}`])
}
if (hasRemote(repoPath)) {
try {
git(repoPath, ['push'])
} catch (err) {
logger.warn({ err, repoPath }, 'GNAP push to remote failed (continuing)')
}
}
}
export function removeTaskFromGnap(taskId: number, repoPath: string): void {
const filePath = path.join(repoPath, 'tasks', `mc-${taskId}.json`)
if (!fs.existsSync(filePath)) return
git(repoPath, ['rm', path.relative(repoPath, filePath)])
if (hasChanges(repoPath)) {
git(repoPath, ['commit', '-m', `Remove task mc-${taskId}`])
}
if (hasRemote(repoPath)) {
try {
git(repoPath, ['push'])
} catch (err) {
logger.warn({ err, repoPath }, 'GNAP push to remote failed (continuing)')
}
}
}
export function pullTasksFromGnap(repoPath: string): GnapTask[] {
const tasksDir = path.join(repoPath, 'tasks')
if (!fs.existsSync(tasksDir)) return []
// Pull remote changes first if available
if (hasRemote(repoPath)) {
try {
git(repoPath, ['pull', '--rebase'])
} catch (err) {
logger.warn({ err, repoPath }, 'GNAP pull from remote failed (using local)')
}
}
const files = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'))
const tasks: GnapTask[] = []
for (const file of files) {
try {
const content = fs.readFileSync(path.join(tasksDir, file), 'utf-8')
tasks.push(JSON.parse(content))
} catch (err) {
logger.warn({ err, file }, 'Failed to parse GNAP task file')
}
}
return tasks
}
export interface SyncResult {
pushed: number
pulled: number
errors: string[]
lastSync: string
}
export function syncGnap(repoPath: string): SyncResult {
const result: SyncResult = {
pushed: 0,
pulled: 0,
errors: [],
lastSync: new Date().toISOString(),
}
// Pull remote if available
if (hasRemote(repoPath)) {
try {
git(repoPath, ['pull', '--rebase'])
} catch (err: any) {
result.errors.push(`Pull failed: ${err.message}`)
}
}
// Count local tasks
const tasksDir = path.join(repoPath, 'tasks')
if (fs.existsSync(tasksDir)) {
result.pushed = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')).length
}
// Push if remote available
if (hasRemote(repoPath) && hasChanges(repoPath)) {
try {
git(repoPath, ['add', '.'])
git(repoPath, ['commit', '-m', `Sync from Mission Control at ${result.lastSync}`])
git(repoPath, ['push'])
} catch (err: any) {
result.errors.push(`Push failed: ${err.message}`)
}
}
return result
}
export function getGnapStatus(repoPath: string): {
initialized: boolean
taskCount: number
hasRemote: boolean
remoteUrl: string
} {
const tasksDir = path.join(repoPath, 'tasks')
const initialized = fs.existsSync(path.join(repoPath, 'version'))
const taskCount = initialized && fs.existsSync(tasksDir)
? fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')).length
: 0
let remote = false
let remoteUrl = ''
if (initialized) {
try {
remote = hasRemote(repoPath)
if (remote) {
remoteUrl = git(repoPath, ['remote', 'get-url', 'origin'])
}
} catch { /* no remote */ }
}
return { initialized, taskCount, hasRemote: remote, remoteUrl }
}