@theronap/cortex-mcp 0.9.85 → 0.9.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cortex-mcp.mjs +2 -3
- package/lib/editors/codex.mjs +1 -1
- package/lib/editors/cursor.mjs +1 -1
- package/lib/server.mjs +343 -4
- package/lib/setup.mjs +5 -6
- package/lib/skills.mjs +4 -161
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -50,9 +50,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
50
50
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
51
51
|
` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
|
|
52
52
|
` use-brain [<brain>] where your session captures are saved — no arg shows the current setting\n` +
|
|
53
|
-
` skills
|
|
54
|
-
` skills
|
|
55
|
-
` skills push <file> [--brain <name>] publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
53
|
+
` skills install/repair the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
|
|
54
|
+
` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
56
55
|
` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
|
|
57
56
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
58
57
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
package/lib/editors/codex.mjs
CHANGED
package/lib/editors/cursor.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
promptTimeInjection: false,
|
|
33
33
|
sessionStart: false,
|
|
34
34
|
captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
|
|
35
|
-
skillDir:
|
|
35
|
+
skillDir: null, // Cursor rules/commands dir — revisit in Pillar 2
|
|
36
36
|
docTrigger: 'hook',
|
|
37
37
|
},
|
|
38
38
|
|
package/lib/server.mjs
CHANGED
|
@@ -223,6 +223,61 @@ export async function runServer(version) {
|
|
|
223
223
|
},
|
|
224
224
|
)
|
|
225
225
|
|
|
226
|
+
server.registerTool(
|
|
227
|
+
'maintenance_candidates',
|
|
228
|
+
{
|
|
229
|
+
title: 'Review recent project-linked maintenance evidence',
|
|
230
|
+
description: 'Start here when preparing a handoff, status, or next step for a NAMED project. Returns a bounded, RLS-scoped set of recent raw records already linked to that project, before you trust the authored page. These are candidate evidence, not a command to edit: read them, call project_status, then decide whether a material contradiction warrants a minimal accountable correction. When correcting, retain the decisive factual qualifier (for example completion date or state) in the current-status text. Never mutate merely to clear a candidate.',
|
|
231
|
+
inputSchema: {
|
|
232
|
+
project: z.string().describe('the project key or exact project name from the task, e.g. "checkout-v2" or "Checkout v2"'),
|
|
233
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
234
|
+
since_days: z.number().optional().describe('how far back to inspect (1-90 days, default 30)'),
|
|
235
|
+
limit: z.number().optional().describe('max candidate records (1-50, default 20)'),
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
async ({ project, brain, since_days, limit }) => {
|
|
239
|
+
const qs = new URLSearchParams({ project })
|
|
240
|
+
if (brain) qs.set('brain', brain)
|
|
241
|
+
if (since_days != null) qs.set('since_days', String(since_days))
|
|
242
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
243
|
+
const res = await fetchCortex(`${BASE}/api/maintenance/candidates?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
const body = await res.text()
|
|
246
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
247
|
+
}
|
|
248
|
+
const { text } = await res.json()
|
|
249
|
+
return { content: [{ type: 'text', text }] }
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
server.registerTool(
|
|
254
|
+
'gate3_status',
|
|
255
|
+
{
|
|
256
|
+
title: 'Gate 3 currency monitor',
|
|
257
|
+
description: 'Read the aggregate-only Gate 3 currency-monitor status for this brain. It counts explicit maintenance-candidate reviews and their same-session durable corrections in the rolling window; it never exposes project names, evidence content, or session keys. "machine_evidence_ready" means enough ordinary-work evidence has accumulated to request a human closure decision, not that the monitor closes the gate itself.',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
days: z.number().optional().describe('rolling window in days (1-90, default 14)'),
|
|
260
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
async ({ days, brain }) => {
|
|
264
|
+
const qs = new URLSearchParams()
|
|
265
|
+
if (days != null) qs.set('days', String(days))
|
|
266
|
+
if (brain) qs.set('brain', brain)
|
|
267
|
+
const suffix = qs.size ? `?${qs}` : ''
|
|
268
|
+
const res = await fetchCortex(`${BASE}/api/gates/3/status${suffix}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const body = await res.text()
|
|
271
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
272
|
+
}
|
|
273
|
+
const status = await res.json()
|
|
274
|
+
const text = status.machineEvidenceReady
|
|
275
|
+
? `Gate 3 machine evidence is ready: ${status.maintenanceReviewSessions} independent maintenance-review sessions and ${status.correctionSessions} correction sessions in ${status.days} days. A human should confirm these were ordinary work before closing the gate.`
|
|
276
|
+
: `Gate 3 is still collecting evidence: ${status.maintenanceReviewSessions}/${status.requiredReviewSessions} independent maintenance-review sessions and ${status.correctionSessions}/${status.requiredCorrectionSessions} correction sessions in ${status.days} days. No gate decision has been made.`
|
|
277
|
+
return { content: [{ type: 'text', text }] }
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
226
281
|
server.registerTool(
|
|
227
282
|
'session_context',
|
|
228
283
|
{
|
|
@@ -333,6 +388,122 @@ export async function runServer(version) {
|
|
|
333
388
|
},
|
|
334
389
|
)
|
|
335
390
|
|
|
391
|
+
// ── Gate 4 private intake (Slice 3) ──────────────────────────────────────────────────────────
|
|
392
|
+
server.registerTool(
|
|
393
|
+
'intake_changes',
|
|
394
|
+
{
|
|
395
|
+
title: 'Check private intake changes',
|
|
396
|
+
description:
|
|
397
|
+
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. If cleanupDueCount > 0, call intake_claim with claimKind=cleanup before ordinary work.',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
|
|
400
|
+
limit: z.number().optional().describe('max change rows (default 100)'),
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
async ({ afterSeq, limit }) => {
|
|
404
|
+
const params = new URLSearchParams()
|
|
405
|
+
if (typeof afterSeq === 'number') params.set('afterSeq', String(afterSeq))
|
|
406
|
+
if (typeof limit === 'number') params.set('limit', String(limit))
|
|
407
|
+
const qs = params.toString() ? `?${params}` : ''
|
|
408
|
+
const res = await fetchCortex(`${BASE}/api/intake/changes${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
const body = await res.text()
|
|
411
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
412
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
413
|
+
}
|
|
414
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
server.registerTool(
|
|
419
|
+
'intake_claim',
|
|
420
|
+
{
|
|
421
|
+
title: 'Claim private intake items',
|
|
422
|
+
description:
|
|
423
|
+
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. If cleanup is due, relevance claims return 409 — process cleanup first. Requires x-cortex-session-key (set automatically by this MCP server).',
|
|
424
|
+
inputSchema: {
|
|
425
|
+
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
426
|
+
limit: z.number().optional().describe('max items (default 10)'),
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
async ({ claimKind, limit }) => {
|
|
430
|
+
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
433
|
+
body: JSON.stringify({ claimKind: claimKind ?? 'relevance', limit, includePayload: true }),
|
|
434
|
+
})
|
|
435
|
+
if (!res.ok) {
|
|
436
|
+
const body = await res.text()
|
|
437
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
438
|
+
if (res.status === 409) return toolError(body)
|
|
439
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
440
|
+
}
|
|
441
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
server.registerTool(
|
|
446
|
+
'intake_materialize',
|
|
447
|
+
{
|
|
448
|
+
title: 'Materialize a private intake item',
|
|
449
|
+
description:
|
|
450
|
+
'Atomically publish a claimed intake item into one brain. Deterministic identifier homes in that brain are always attached; documentIds may add further pages. Sets record confidentiality to the strictest attached page tier. Never writes private intake into search/history before this call.',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
intakeItemId: z.string().describe('intake item uuid'),
|
|
453
|
+
orgId: z.string().describe('destination brain org uuid'),
|
|
454
|
+
documentIds: z.array(z.string()).optional().describe('additional brain_documents ids in orgId (deterministic homes are merged automatically)'),
|
|
455
|
+
title: z.string().optional(),
|
|
456
|
+
summary: z.string().optional(),
|
|
457
|
+
source: z.string().optional(),
|
|
458
|
+
recordType: z.string().optional(),
|
|
459
|
+
dedupeKey: z.string().optional(),
|
|
460
|
+
origin: z.enum(['deterministic', 'llm', 'user']).optional(),
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
async (args) => {
|
|
464
|
+
const res = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
intakeItemId: args.intakeItemId,
|
|
469
|
+
orgId: args.orgId,
|
|
470
|
+
documentIds: args.documentIds ?? [],
|
|
471
|
+
record: {
|
|
472
|
+
title: args.title,
|
|
473
|
+
summary: args.summary,
|
|
474
|
+
source: args.source,
|
|
475
|
+
record_type: args.recordType,
|
|
476
|
+
dedupe_key: args.dedupeKey,
|
|
477
|
+
},
|
|
478
|
+
attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
|
|
479
|
+
}),
|
|
480
|
+
})
|
|
481
|
+
if (!res.ok) {
|
|
482
|
+
const body = await res.text()
|
|
483
|
+
return toolError(body)
|
|
484
|
+
}
|
|
485
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
server.registerTool(
|
|
490
|
+
'intake_cleanup_status',
|
|
491
|
+
{
|
|
492
|
+
title: 'Private intake cleanup status',
|
|
493
|
+
description: 'Counts of pending/claimed/awaiting/cleanup-due intake items plus open clarifying questions for the owner.',
|
|
494
|
+
inputSchema: {},
|
|
495
|
+
},
|
|
496
|
+
async () => {
|
|
497
|
+
const res = await fetchCortex(`${BASE}/api/intake/cleanup-status`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
498
|
+
if (!res.ok) {
|
|
499
|
+
const body = await res.text()
|
|
500
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
501
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
502
|
+
}
|
|
503
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
|
|
336
507
|
server.registerTool(
|
|
337
508
|
'search_org',
|
|
338
509
|
{
|
|
@@ -1451,6 +1622,174 @@ export async function runServer(version) {
|
|
|
1451
1622
|
},
|
|
1452
1623
|
)
|
|
1453
1624
|
|
|
1625
|
+
server.registerTool(
|
|
1626
|
+
'set_routing_identifier',
|
|
1627
|
+
{
|
|
1628
|
+
title: 'Claim a routing identifier on a page',
|
|
1629
|
+
description: 'Declare that a page is a Gate 4 attach HOME for an identifier (repo:owner/name or file:owner/name:path). Body [[repo:…]] stamps are navigation only and do NOT drive attach — use this instead. Prefer the narrowest id: mother pages own repo:; feature pages own file: paths. Do not copy every body mention into a routing claim.',
|
|
1630
|
+
inputSchema: {
|
|
1631
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1632
|
+
name: z.string().optional().describe('page title (or pass ref)'),
|
|
1633
|
+
ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
|
|
1634
|
+
brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
|
|
1635
|
+
identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex or file:theronap/cortex:web/lib/engine/github_intake.ts'),
|
|
1636
|
+
},
|
|
1637
|
+
},
|
|
1638
|
+
async ({ kind, name, ref, brain, identifier }) => {
|
|
1639
|
+
if (!name && !ref) return toolError('Pass name or ref')
|
|
1640
|
+
let res
|
|
1641
|
+
try {
|
|
1642
|
+
res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
|
|
1643
|
+
method: 'POST',
|
|
1644
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1645
|
+
body: JSON.stringify({ kind, name, ref, brain, identifier }),
|
|
1646
|
+
})
|
|
1647
|
+
} catch (e) {
|
|
1648
|
+
return toolError(`Could not set routing identifier: ${e.message}`)
|
|
1649
|
+
}
|
|
1650
|
+
const out = await res.json().catch(() => null)
|
|
1651
|
+
if (!res.ok) return toolError(`Could not set routing identifier: ${out?.error ?? res.status}`)
|
|
1652
|
+
const set = out?.set?.join(', ') ?? identifier
|
|
1653
|
+
return { content: [{ type: 'text', text: `Routing identifier set on document ${out?.documentId ?? '?'}: ${set}. Future matching events will attach here (body mentions alone will not).` }] }
|
|
1654
|
+
},
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
// ── Gate 4 record triage ──────────────────────────────────────────────────────────────────
|
|
1658
|
+
// A connector event materializes in seconds and has no idea what the work WAS. The session that
|
|
1659
|
+
// did the work knows exactly, and arrives later. These three tools are that handoff: look at what
|
|
1660
|
+
// landed, claim what is yours, route it when you know where it goes.
|
|
1661
|
+
|
|
1662
|
+
server.registerTool(
|
|
1663
|
+
'pending_records',
|
|
1664
|
+
{
|
|
1665
|
+
title: 'Records waiting for a home',
|
|
1666
|
+
description: 'List connector records (GitHub pushes, PRs, email) that landed WITHOUT a confident home and are waiting for judgment. Check this when your session starts if the headline count sounds related to what you are about to work on — records from your own recent commits are usually in here. Returns titles and current homes only, never payloads. Use view "sweep" on one record to get graded page-name candidates without reading any pages.',
|
|
1667
|
+
inputSchema: {
|
|
1668
|
+
view: z.enum(['digest', 'sweep']).optional().describe('digest = what is waiting (default); sweep = cheap graded candidates for one record'),
|
|
1669
|
+
recordId: z.string().optional().describe('required for view "sweep"'),
|
|
1670
|
+
hours: z.number().int().positive().optional().describe('lookback window, default 3'),
|
|
1671
|
+
stale: z.boolean().optional().describe('include old unclaimed records — the cleanup pile, not the live one'),
|
|
1672
|
+
limit: z.number().int().positive().optional(),
|
|
1673
|
+
},
|
|
1674
|
+
},
|
|
1675
|
+
async ({ view, recordId, hours, stale, limit }) => {
|
|
1676
|
+
const params = new URLSearchParams()
|
|
1677
|
+
params.set('view', view === 'sweep' ? 'sweep' : 'digest')
|
|
1678
|
+
if (recordId) params.set('recordId', recordId)
|
|
1679
|
+
if (hours) params.set('hours', String(hours))
|
|
1680
|
+
if (stale) params.set('stale', '1')
|
|
1681
|
+
if (limit) params.set('limit', String(limit))
|
|
1682
|
+
|
|
1683
|
+
let res
|
|
1684
|
+
try {
|
|
1685
|
+
res = await fetchCortex(`${BASE}/api/brain/triage?${params}`, {
|
|
1686
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
1687
|
+
})
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
return toolError(`Could not read pending records: ${e.message}`)
|
|
1690
|
+
}
|
|
1691
|
+
const out = await res.json().catch(() => null)
|
|
1692
|
+
if (!res.ok) return toolError(`Could not read pending records: ${out?.error ?? res.status}`)
|
|
1693
|
+
|
|
1694
|
+
if (view === 'sweep') {
|
|
1695
|
+
const cands = out?.candidates ?? []
|
|
1696
|
+
if (cands.length === 0) {
|
|
1697
|
+
return { content: [{ type: 'text', text: `No page-name candidates for "${out?.title ?? recordId}". Park it — a stale record with no match is not worth reading pages over.` }] }
|
|
1698
|
+
}
|
|
1699
|
+
const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
|
|
1700
|
+
const rec = out?.recommendation
|
|
1701
|
+
return {
|
|
1702
|
+
content: [{
|
|
1703
|
+
type: 'text',
|
|
1704
|
+
text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\nRecommended: ${rec?.action} — ${rec?.why}\n● strong = titles contain each other, safe to route. ○ weak = one generic word matched; read those pages ONLY if this record is worth the tokens, otherwise park.`,
|
|
1705
|
+
}],
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const records = out?.records ?? []
|
|
1710
|
+
if (records.length === 0) {
|
|
1711
|
+
return { content: [{ type: 'text', text: 'Nothing waiting for a home.' }] }
|
|
1712
|
+
}
|
|
1713
|
+
const lines = records.map((r) => {
|
|
1714
|
+
const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
|
|
1715
|
+
const held = r.claimedBySession ? ` [claimed: ${String(r.claimedBySession).slice(0, 12)}…]` : ''
|
|
1716
|
+
return ` • ${r.title}\n ${r.source} · ${r.ageHours}h ago · on: ${homes}${held}\n ${r.recordId}`
|
|
1717
|
+
})
|
|
1718
|
+
return {
|
|
1719
|
+
content: [{
|
|
1720
|
+
type: 'text',
|
|
1721
|
+
text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.`,
|
|
1722
|
+
}],
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
server.registerTool(
|
|
1728
|
+
'claim_record',
|
|
1729
|
+
{
|
|
1730
|
+
title: 'Claim a pending record as your work',
|
|
1731
|
+
description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim stops a cheap automatic sweep from guessing at something you have real context on. Claims expire, so a dead session never holds a record hostage. Pass release=true to give one back when it turns out not to be yours.',
|
|
1732
|
+
inputSchema: {
|
|
1733
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1734
|
+
note: z.string().optional().describe('what you think this is — kept for audit'),
|
|
1735
|
+
leaseMinutes: z.number().int().positive().optional().describe('how long you need it, default 90'),
|
|
1736
|
+
release: z.boolean().optional().describe('give the claim back instead of taking it'),
|
|
1737
|
+
},
|
|
1738
|
+
},
|
|
1739
|
+
async ({ recordId, note, leaseMinutes, release }) => {
|
|
1740
|
+
let res
|
|
1741
|
+
try {
|
|
1742
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1743
|
+
method: 'POST',
|
|
1744
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1745
|
+
body: JSON.stringify({ action: release ? 'release' : 'claim', recordId, note, leaseMinutes }),
|
|
1746
|
+
})
|
|
1747
|
+
} catch (e) {
|
|
1748
|
+
return toolError(`Could not claim record: ${e.message}`)
|
|
1749
|
+
}
|
|
1750
|
+
const out = await res.json().catch(() => null)
|
|
1751
|
+
if (!res.ok) {
|
|
1752
|
+
if (out?.error === 'already_claimed') {
|
|
1753
|
+
return toolError(`Another live session is already holding that record (${String(out.heldBy).slice(0, 16)}…). Leave it to them.`)
|
|
1754
|
+
}
|
|
1755
|
+
return toolError(`Could not claim record: ${out?.error ?? res.status}`)
|
|
1756
|
+
}
|
|
1757
|
+
if (release) return { content: [{ type: 'text', text: 'Released — it is back in the pending pool.' }] }
|
|
1758
|
+
return { content: [{ type: 'text', text: `Claimed until ${out?.expiresAt ?? 'the lease expires'}. Call route_record when you know where it belongs.` }] }
|
|
1759
|
+
},
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
server.registerTool(
|
|
1763
|
+
'route_record',
|
|
1764
|
+
{
|
|
1765
|
+
title: 'Route a record to the pages it belongs on',
|
|
1766
|
+
description: 'Attach a pending record to the pages you judge correct — the point of the whole triage path. Use this when you have real context on what the work was; that judgment is better than any rule the webhook could run. Attachments are additive: existing deterministic homes (routing identifiers, your profile) stay. Pass park=true instead when you have looked and there is genuinely no good home — parking beats attaching to a page that merely shares a word.',
|
|
1767
|
+
inputSchema: {
|
|
1768
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1769
|
+
documentIds: z.array(z.string()).optional().describe('page document ids to attach (from pending_records sweep, or read_page)'),
|
|
1770
|
+
reason: z.string().describe('why these pages — recorded with the attachment'),
|
|
1771
|
+
park: z.boolean().optional().describe('no good home exists; leave it alone rather than guessing'),
|
|
1772
|
+
tier: z.number().int().optional().describe('2 when this came from the cheap sweep rather than your own context'),
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
async ({ recordId, documentIds, reason, park, tier }) => {
|
|
1776
|
+
let res
|
|
1777
|
+
try {
|
|
1778
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1779
|
+
method: 'POST',
|
|
1780
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1781
|
+
body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier }),
|
|
1782
|
+
})
|
|
1783
|
+
} catch (e) {
|
|
1784
|
+
return toolError(`Could not route record: ${e.message}`)
|
|
1785
|
+
}
|
|
1786
|
+
const out = await res.json().catch(() => null)
|
|
1787
|
+
if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
1788
|
+
if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
|
|
1789
|
+
return { content: [{ type: 'text', text: `Routed — attached to ${out?.attached?.length ?? 0} page(s). Recorded as a session judgment, not a rule match.` }] }
|
|
1790
|
+
},
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1454
1793
|
server.registerTool(
|
|
1455
1794
|
'snooze_red_link',
|
|
1456
1795
|
{
|
|
@@ -1699,7 +2038,7 @@ export async function runServer(version) {
|
|
|
1699
2038
|
'request_person_page_merge',
|
|
1700
2039
|
{
|
|
1701
2040
|
title: 'Offer one duplicate person page for merge',
|
|
1702
|
-
description: 'Offer YOUR accessible person
|
|
2041
|
+
description: 'Offer YOUR accessible person NODE for full merge into another current accessible person node in the SAME named brain. Use only when the two nodes are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
|
|
1703
2042
|
inputSchema: {
|
|
1704
2043
|
brain: z.string().describe('the exact brain name or brain id; pass an id if names collide'),
|
|
1705
2044
|
source_ref: z.string().describe('ref of YOUR duplicate source person page'),
|
|
@@ -1727,7 +2066,7 @@ export async function runServer(version) {
|
|
|
1727
2066
|
'person_page_merge_requests',
|
|
1728
2067
|
{
|
|
1729
2068
|
title: 'Review duplicate-person page merge requests',
|
|
1730
|
-
description: 'List pending same-brain person-
|
|
2069
|
+
description: 'List pending same-brain person-node merges where you own the canonical page. Read both pages before applying. Applying preserves source page prose/history, moves its raw person attachments (mentions, actor evidence, identifiers, aliases and graph edges) onto the canonical node, and leaves a hidden source alias plus redirect. It refuses conflicting private variants rather than widening them.',
|
|
1731
2070
|
inputSchema: {},
|
|
1732
2071
|
},
|
|
1733
2072
|
async () => {
|
|
@@ -1746,7 +2085,7 @@ export async function runServer(version) {
|
|
|
1746
2085
|
'apply_person_page_merge',
|
|
1747
2086
|
{
|
|
1748
2087
|
title: 'Apply a reviewed duplicate-person page merge',
|
|
1749
|
-
description: 'Apply a pending same-brain person-
|
|
2088
|
+
description: 'Apply a pending full same-brain person-node merge you own. Re-read the canonical page immediately before applying and pass its version. This is atomic and preservation-first: source prose/history is retained, raw evidence and connections are re-pointed to the canonical node, identifiers are unioned, and the source becomes a hidden alias with a durable redirect. A privacy or identity-metadata conflict refuses the whole merge.',
|
|
1750
2089
|
inputSchema: {
|
|
1751
2090
|
request_id: z.string().describe('id from person_page_merge_requests'),
|
|
1752
2091
|
target_version: z.string().describe('current canonical-page version from a fresh read'),
|
|
@@ -1763,7 +2102,7 @@ export async function runServer(version) {
|
|
|
1763
2102
|
} catch (e) { return toolError(`Could not apply page merge: ${e.message}`) }
|
|
1764
2103
|
const out = await res.json().catch(() => null)
|
|
1765
2104
|
if (!res.ok) return toolError(`Could not apply page merge: ${out?.error ?? res.status}`)
|
|
1766
|
-
return { content: [{ type: 'text', text: `
|
|
2105
|
+
return { content: [{ type: 'text', text: `Fully merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source page block(s) and moved ${out.movedAttachments} raw attachment(s); source is now a hidden alias with a durable redirect.` }] }
|
|
1767
2106
|
},
|
|
1768
2107
|
)
|
|
1769
2108
|
|
package/lib/setup.mjs
CHANGED
|
@@ -141,16 +141,15 @@ export async function runSetup(argv, version) {
|
|
|
141
141
|
process.exit(1)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// ── 3. Managed skills — installed flat into every
|
|
145
|
-
// Bundled first (sync, network-free), then org
|
|
146
|
-
//
|
|
147
|
-
// only if the caller exported it;
|
|
144
|
+
// ── 3. Managed skills — installed flat into every agent CLI present (Claude + Codex) ──
|
|
145
|
+
// Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
|
|
146
|
+
// seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
|
|
147
|
+
// only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
|
|
148
148
|
try {
|
|
149
149
|
installSkills({ quiet: false })
|
|
150
|
-
const { syncOrgSkills
|
|
150
|
+
const { syncOrgSkills } = await import('./skills.mjs')
|
|
151
151
|
process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
|
|
152
152
|
await syncOrgSkills({ quiet: false })
|
|
153
|
-
await syncPersonalSkills({ quiet: false })
|
|
154
153
|
} catch (e) {
|
|
155
154
|
// Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
|
|
156
155
|
log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
|
package/lib/skills.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
|
|
|
5
5
|
|
|
6
6
|
// Managed Agnoclast skills.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Two sources, one install pipeline:
|
|
9
9
|
// 1. BUNDLED — shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
|
|
10
10
|
// Inalterable: canonical content always wins; user edits are backed up + restored.
|
|
11
11
|
// 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
|
|
@@ -13,9 +13,6 @@ import { fileURLToPath } from 'url'
|
|
|
13
13
|
// SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
|
|
14
14
|
// so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
|
|
15
15
|
// at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
|
|
16
|
-
// 3. PERSONAL — rows in `personal_skills`, keyed by auth_id. These are private to one person and
|
|
17
|
-
// are projected into every compatible editor on their machines. Personal wins an org collision
|
|
18
|
-
// on that person's machine; bundled core still wins every collision.
|
|
19
16
|
//
|
|
20
17
|
// Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
|
|
21
18
|
// ~/.claude/skills/<name>/SKILL.md (Claude Code)
|
|
@@ -33,16 +30,13 @@ import { fileURLToPath } from 'url'
|
|
|
33
30
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
34
31
|
const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
|
|
35
32
|
const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
|
|
36
|
-
const PERSONAL_CACHE = join(homedir(), '.cortex', 'personal-skills-cache.json')
|
|
37
33
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
|
|
38
34
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// deliberately do not fabricate one.
|
|
35
|
+
// Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
|
|
36
|
+
// Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
|
|
42
37
|
const CLIS = [
|
|
43
38
|
{ id: 'Claude Code', dir: join(homedir(), '.claude') },
|
|
44
39
|
{ id: 'Codex', dir: join(homedir(), '.codex') },
|
|
45
|
-
{ id: 'Cursor', dir: join(homedir(), '.cursor') },
|
|
46
40
|
]
|
|
47
41
|
|
|
48
42
|
// djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
|
|
@@ -202,25 +196,6 @@ export function planOrgInstall(served, bundledNames) {
|
|
|
202
196
|
return { install, skipped }
|
|
203
197
|
}
|
|
204
198
|
|
|
205
|
-
// Personal skills share the same validation and bundled-collision rule as org skills, but never use
|
|
206
|
-
// the cross-brain collision rule: their auth_id uniqueness means only one personal body can exist per
|
|
207
|
-
// name. Keeping this pure makes the precedence contract testable without touching a real home dir.
|
|
208
|
-
export function planPersonalInstall(served, bundledNames) {
|
|
209
|
-
const bundled = new Set(bundledNames)
|
|
210
|
-
const install = []
|
|
211
|
-
const skipped = []
|
|
212
|
-
const seen = new Set()
|
|
213
|
-
for (const s of served ?? []) {
|
|
214
|
-
const name = (s?.name ?? '').trim()
|
|
215
|
-
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
216
|
-
if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
|
|
217
|
-
if (seen.has(name)) { skipped.push({ name, why: 'duplicate personal skill' }); continue }
|
|
218
|
-
seen.add(name)
|
|
219
|
-
install.push({ name, source: s.body_md })
|
|
220
|
-
}
|
|
221
|
-
return { install, skipped }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
199
|
/**
|
|
225
200
|
* Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
|
|
226
201
|
* no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
|
|
@@ -290,76 +265,6 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
290
265
|
return summary
|
|
291
266
|
}
|
|
292
267
|
|
|
293
|
-
/**
|
|
294
|
-
* Pull the caller's personal skill library and project it into every compatible local editor.
|
|
295
|
-
* It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
|
|
296
|
-
* session start. A personal skill wins an unpinned org skill by being installed after org sync.
|
|
297
|
-
*/
|
|
298
|
-
export async function syncPersonalSkills(opts = {}) {
|
|
299
|
-
const quiet = !!opts.quiet
|
|
300
|
-
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
301
|
-
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
302
|
-
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
303
|
-
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
304
|
-
if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
|
|
305
|
-
|
|
306
|
-
let cache = null
|
|
307
|
-
try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
|
|
308
|
-
let served = null
|
|
309
|
-
try {
|
|
310
|
-
const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
|
|
311
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
312
|
-
})
|
|
313
|
-
if (res.ok) {
|
|
314
|
-
served = (await res.json())?.skills ?? []
|
|
315
|
-
summary.source = 'server'
|
|
316
|
-
} else {
|
|
317
|
-
log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
318
|
-
}
|
|
319
|
-
} catch { /* fall through to cache */ }
|
|
320
|
-
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
321
|
-
if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
|
|
322
|
-
|
|
323
|
-
const bundledNames = bundledSkills().map((s) => s.name)
|
|
324
|
-
const { install, skipped } = planPersonalInstall(served, bundledNames)
|
|
325
|
-
summary.skipped = skipped
|
|
326
|
-
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
327
|
-
const currentNames = install.map((s) => s.name)
|
|
328
|
-
const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
329
|
-
|
|
330
|
-
// If a personal override was disabled, put back a cached org skill of that name rather than
|
|
331
|
-
// deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
|
|
332
|
-
let orgCache = null
|
|
333
|
-
try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
|
|
334
|
-
const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
|
|
335
|
-
const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
|
|
336
|
-
const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
|
|
337
|
-
const removeNames = retiredNames.filter((name) => !orgByName.has(name))
|
|
338
|
-
|
|
339
|
-
for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
|
|
340
|
-
// Fallbacks first, personal second: personal > org, but bundled was installed before either.
|
|
341
|
-
const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
|
|
342
|
-
summary.installed.push(...r.installed)
|
|
343
|
-
summary.repaired.push(...r.repaired)
|
|
344
|
-
summary.removed.push(...r.removed)
|
|
345
|
-
}
|
|
346
|
-
if (summary.source === 'server') {
|
|
347
|
-
ensureDir(dirname(PERSONAL_CACHE))
|
|
348
|
-
writeFileSync(PERSONAL_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const changed = [...new Set([...summary.installed, ...summary.repaired])]
|
|
352
|
-
if (changed.length || summary.removed.length) {
|
|
353
|
-
const bits = []
|
|
354
|
-
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
355
|
-
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
356
|
-
process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
357
|
-
} else if (!quiet) {
|
|
358
|
-
log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
359
|
-
}
|
|
360
|
-
return summary
|
|
361
|
-
}
|
|
362
|
-
|
|
363
268
|
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
364
269
|
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
365
270
|
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
@@ -450,69 +355,10 @@ export async function runSkillsPush(argv) {
|
|
|
450
355
|
}
|
|
451
356
|
}
|
|
452
357
|
|
|
453
|
-
export function discoverSkillFiles(root) {
|
|
454
|
-
if (!root || !existsSync(root)) return []
|
|
455
|
-
return readdirSync(root, { withFileTypes: true })
|
|
456
|
-
.filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, 'SKILL.md')))
|
|
457
|
-
.map((entry) => join(root, entry.name, 'SKILL.md'))
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// `skills import --from claude` is the deliberate migration command. It uploads only the user's
|
|
461
|
-
// skill bodies to their private library, then projects the served result; it never publishes to an
|
|
462
|
-
// organization and it never treats a local copy as proof that server sync worked.
|
|
463
|
-
export async function runSkillsImport(argv) {
|
|
464
|
-
if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
|
|
465
|
-
process.stderr.write('Usage: skills import --from claude\n')
|
|
466
|
-
return 1
|
|
467
|
-
}
|
|
468
|
-
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
469
|
-
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
470
|
-
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
471
|
-
const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
|
|
472
|
-
if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
|
|
473
|
-
|
|
474
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
475
|
-
let imported = 0
|
|
476
|
-
const skipped = []
|
|
477
|
-
const seen = new Set()
|
|
478
|
-
for (const file of files) {
|
|
479
|
-
const body_md = readFileSync(file, 'utf8')
|
|
480
|
-
const name = frontmatterName(body_md, '').toLowerCase()
|
|
481
|
-
if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
|
|
482
|
-
if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
|
|
483
|
-
seen.add(name)
|
|
484
|
-
try {
|
|
485
|
-
const res = await fetchCortex(`${base}/api/personal-skills`, {
|
|
486
|
-
method: 'POST',
|
|
487
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
488
|
-
body: JSON.stringify({ name, body_md, enabled: true }),
|
|
489
|
-
})
|
|
490
|
-
if (!res.ok) {
|
|
491
|
-
const response = await res.json().catch(() => ({}))
|
|
492
|
-
skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
|
|
493
|
-
continue
|
|
494
|
-
}
|
|
495
|
-
imported++
|
|
496
|
-
} catch (e) {
|
|
497
|
-
skipped.push(`${file}: network error: ${e.message}`)
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
|
|
501
|
-
for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
|
|
502
|
-
if (imported) await syncPersonalSkills({ quiet: false })
|
|
503
|
-
return skipped.length ? 1 : 0
|
|
504
|
-
}
|
|
505
|
-
|
|
506
358
|
// CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
|
|
507
359
|
// are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
|
|
508
360
|
export async function runSkills(argv = []) {
|
|
509
361
|
if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
|
|
510
|
-
if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
|
|
511
|
-
const syncing = argv[0] === 'sync'
|
|
512
|
-
if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
|
|
513
|
-
process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
|
|
514
|
-
return 1
|
|
515
|
-
}
|
|
516
362
|
|
|
517
363
|
const quiet = argv.includes('--quiet')
|
|
518
364
|
if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
|
|
@@ -523,9 +369,6 @@ export async function runSkills(argv = []) {
|
|
|
523
369
|
process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
|
|
524
370
|
}
|
|
525
371
|
}
|
|
526
|
-
if (r.targets.length) {
|
|
527
|
-
await syncOrgSkills({ quiet })
|
|
528
|
-
await syncPersonalSkills({ quiet })
|
|
529
|
-
}
|
|
372
|
+
if (r.targets.length) await syncOrgSkills({ quiet })
|
|
530
373
|
return 0
|
|
531
374
|
}
|