drafted 1.12.8 → 1.13.0

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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Drafted
2
+
3
+ Multi-tenant shared surface for AI-human collaboration. Drafted organizes agent-produced work into **projects** (frames on a zoomable real-time surface), **skills** (reusable operating procedures agents load), and a **wiki** (durable org knowledge) — with MCP tools for agents and a browser UI for humans.
4
+
5
+ - Product vision and positioning: [PRODUCT.md](PRODUCT.md)
6
+ - Architecture, development, and operations: [AGENTS.md](AGENTS.md)
7
+ - Live: [drafted.live](https://drafted.live)
8
+
9
+ ## OKF-native
10
+
11
+ Drafted is a native producer and consumer of the **Open Knowledge Format (OKF) v0.1** ([GoogleCloudPlatform/knowledge-catalog](https://github.com/GoogleCloudPlatform/knowledge-catalog)):
12
+
13
+ - **The org wiki is an OKF v0.1 bundle.** Every page carries conformant YAML frontmatter (required `type`, one-line `description`, `tags`, synthesized `timestamp`; unknown keys preserved). `index.md` at every level is synthesized, `log.md` keeps the date-grouped change history, links resolve with or without `.md`, and broken links are legal. The wiki UI shows a live OKF conformance badge.
14
+ - **Bundle exchange everywhere.** Export/import the wiki (`GET /api/wiki/export.tar.gz`, `POST /api/wiki/import`), the skill library (`skills/<slug>/SKILL.md` layout), and whole projects (`<layer>/<lane>/<file>.md` concepts; markdown links round-trip as connectors) — via HTTP or the MCP `wiki`, `skill`, and `project` tools, with dry-run reports.
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ npm install && npm run dev # Docker services + schema + server with hot reload
20
+ ```
21
+
22
+ - App: http://localhost:3477
23
+ - Email inbox (magic links): http://localhost:8025
24
+ - MinIO console: http://localhost:9001
25
+
26
+ Sign in with any email and grab the magic link from Mailpit.
package/cli/drafted.mjs CHANGED
@@ -1440,6 +1440,18 @@ function synthesizeSkillMd(skill) {
1440
1440
  lines.push('setup:');
1441
1441
  for (const s of setup) lines.push(` - ${JSON.stringify(s)}`);
1442
1442
  }
1443
+ // OKF v0.1 concept keys — byte-identical to the server authority
1444
+ // (server/lib/skill-hash.mjs synthesizeSkillMd); keep in lockstep so a
1445
+ // locally materialized bundle matches the server-side hash input.
1446
+ lines.push('type: Skill');
1447
+ lines.push(`title: ${JSON.stringify(skill.name || skill.slug || '')}`);
1448
+ const skillTags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
1449
+ if (skillTags.length) {
1450
+ lines.push('tags:');
1451
+ for (const t of skillTags) lines.push(` - ${JSON.stringify(t)}`);
1452
+ }
1453
+ const ts = skill.updatedAt ? new Date(skill.updatedAt) : null;
1454
+ if (ts && !Number.isNaN(ts.getTime())) lines.push(`timestamp: ${JSON.stringify(ts.toISOString())}`);
1443
1455
  lines.push('---', '');
1444
1456
  return lines.join('\n') + (skill.content || '');
1445
1457
  }
package/mcp/server.mjs CHANGED
@@ -20,6 +20,7 @@ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/e
20
20
  import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
22
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
+ import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
23
24
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
24
25
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
25
26
 
@@ -1619,9 +1620,9 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
1619
1620
 
1620
1621
  // ── Project management tools (direct HTTP) ────────────────────────
1621
1622
 
1622
- tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, shape, group, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
1623
- action: z.enum(['list', 'open', 'create', 'update', 'move']).describe('Operation to perform.'),
1624
- projectId: z.string().optional().describe('[open|update|move] project ID. Get IDs from action=list.'),
1623
+ tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org), export (the project as an OKF v0.1 bundle — <layer>/<lane>/<file>.md concepts, index.md/log.md synthesized), import (ingest an OKF bundle: concepts become markdown document frames, links between them become connectors; dryRun supported). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, shape, group, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
1624
+ action: z.enum(['list', 'open', 'create', 'update', 'move', 'export', 'import']).describe('Operation to perform.'),
1625
+ projectId: z.string().optional().describe('[open|update|move|export|import] project ID. Get IDs from action=list. For export/import: defaults to the bound project.'),
1625
1626
  name: z.string().optional().describe('[create|update] project name'),
1626
1627
  description: z.string().nullable().optional().describe('[create|update] project description'),
1627
1628
  templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
@@ -1630,6 +1631,16 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1630
1631
  layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
1631
1632
  targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
1632
1633
  skipBrowser: z.boolean().optional().describe('[open] skip opening/navigating a browser tab (use when the user already has the project open, e.g. from an invite snippet)'),
1634
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
1635
+ limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
1636
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
1637
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
1638
+ files: z.array(z.object({
1639
+ path: z.string().describe('Bundle-relative file path, e.g. "research/default/notes.md"'),
1640
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
1641
+ })).optional().describe('[import] OKF bundle files inline. index.md/log.md are skipped (synthesized). Caps: 500 files, 512KB/file, 5MB total.'),
1642
+ dryRun: z.boolean().optional().describe('[import] preview the {creates, updates, skips, warnings} report without writing'),
1643
+ ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-project-<slug>). import: recursively read .md files from here (alternative to files[]).') }),
1633
1644
  }, async (args) => {
1634
1645
  try {
1635
1646
  const { action } = args;
@@ -1774,6 +1785,80 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1774
1785
  if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
1775
1786
  return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
1776
1787
  }
1788
+
1789
+ // ── export ──────────────────────────────────────────────────
1790
+ // The project as an OKF v0.1 bundle. Mirrors wiki export: format="files"
1791
+ // pages the bundle inline; otherwise stdio writes a local dir, remote
1792
+ // returns the authenticated tar.gz download URL.
1793
+ case 'export': {
1794
+ const projectId = args.projectId || getState().projectId;
1795
+ if (!projectId) throw new Error('projectId required for action=export (or open a project first)');
1796
+ if (args.format === 'files') {
1797
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
1798
+ if (args.offset) qp.set('offset', String(args.offset));
1799
+ if (args.compact) qp.set('compact', 'true');
1800
+ return ok(await api('GET', `/api/projects/${projectId}/export?${qp.toString()}`));
1801
+ }
1802
+ if (isRemote) {
1803
+ return ok({
1804
+ downloadUrl: `${getServerUrl()}/api/projects/${projectId}/export.tar.gz`,
1805
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 project bundle, or call export with format="files" to page the bundle contents inline.',
1806
+ });
1807
+ }
1808
+ // stdio: write every bundle file under a local directory.
1809
+ const meta = getCurrentProjectContext();
1810
+ const projSlug = String((meta && meta.id === projectId && (meta.slug || meta.name)) || projectId)
1811
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
1812
+ const exportDir = resolve(args.dir || `./okf-project-${projSlug}`);
1813
+ let expOffset = 0;
1814
+ let written = 0;
1815
+ for (;;) {
1816
+ const batch = await api('GET', `/api/projects/${projectId}/export?limit=200&offset=${expOffset}`);
1817
+ const batchFiles = batch.files || [];
1818
+ for (const f of batchFiles) {
1819
+ const dest = resolve(exportDir, f.path);
1820
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
1821
+ mkdirSync(dirname(dest), { recursive: true });
1822
+ writeFileSync(dest, f.content, 'utf8');
1823
+ written++;
1824
+ }
1825
+ expOffset += batchFiles.length;
1826
+ if (!batch.truncated || batchFiles.length === 0) break;
1827
+ }
1828
+ return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 project bundle written (<layer>/<lane>/<file>.md concepts; index.md and log.md are synthesized).' });
1829
+ }
1830
+
1831
+ // ── import ──────────────────────────────────────────────────
1832
+ // Ingest an OKF bundle into the project: inline files[] or (stdio) a
1833
+ // local dir walked for .md files. Concepts become markdown document
1834
+ // frames; markdown links between them become connectors.
1835
+ case 'import': {
1836
+ const projectId = args.projectId || getState().projectId;
1837
+ if (!projectId) throw new Error('projectId required for action=import (or open a project first)');
1838
+ let importFiles = args.files;
1839
+ if (!importFiles && args.dir) {
1840
+ const root = resolve(args.dir);
1841
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
1842
+ importFiles = [];
1843
+ const walk = (d) => {
1844
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
1845
+ if (ent.name.startsWith('.')) continue;
1846
+ const p = join(d, ent.name);
1847
+ if (ent.isDirectory()) walk(p);
1848
+ else if (/\.md$/i.test(ent.name)) {
1849
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
1850
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
1851
+ }
1852
+ }
1853
+ };
1854
+ walk(root);
1855
+ }
1856
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
1857
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
1858
+ }
1859
+ return ok(await api('POST', `/api/projects/${projectId}/import`, { files: importFiles, dryRun: !!args.dryRun }));
1860
+ }
1861
+
1777
1862
  default:
1778
1863
  throw new Error(`Unknown project action: ${action}`);
1779
1864
  }
@@ -3113,7 +3198,7 @@ function ensureSkillInstallIgnored(dir) {
3113
3198
  } catch { return false; }
3114
3199
  }
3115
3200
 
3116
- tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory.', {
3201
+ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory; export/import to exchange the library as an OKF v0.1 bundle (skills/<slug>/SKILL.md layout).', {
3117
3202
  action: z.enum([
3118
3203
  'search', 'load', 'list', 'history',
3119
3204
  'add', 'update', 'remove',
@@ -3121,12 +3206,13 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3121
3206
  'attach', 'detach',
3122
3207
  'favorite', 'unfavorite',
3123
3208
  'read_file', 'update_file',
3124
- ]).describe('Operation to perform.'),
3209
+ 'export', 'import',
3210
+ ]).describe('Operation to perform. export: the skill library as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest the SKILL.md-shaped concepts of an OKF bundle as org skills (files[] or local dir; dryRun supported).'),
3125
3211
  query: z.string().optional().describe('[search] term to match against name/description/content'),
3126
3212
  tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
3127
3213
  scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
3128
- limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100)'),
3129
- compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget'),
3214
+ limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100); [export] files per page for format="files" (default 100, max 500)'),
3215
+ compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget; [export] with format="files": file paths only (no content)'),
3130
3216
  skill: z.string().optional().describe('[load|history] skill ID (UUID) or slug'),
3131
3217
  version: z.number().optional().describe('[history] fetch this version number\'s full snapshot (content included); omit for the reverse-chron version list'),
3132
3218
  skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
@@ -3136,13 +3222,16 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3136
3222
  content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
3137
3223
  triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
3138
3224
  path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
3139
- offset: z.number().optional().describe('[search|list] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
3225
+ offset: z.number().optional().describe('[search|list|export] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
3140
3226
  maxBytes: z.number().optional().describe('[read_file] return at most this many bytes from offset (default: whole remaining file). Response reports totalSize/offset/truncated/nextOffset.'),
3141
- org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update] resolve/fork into this org. Per-request only — nothing is switched.'),
3227
+ org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched.'),
3142
3228
  setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
3143
- files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps'),
3144
- dir: z.string().optional().describe('[push] local directory to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces. On push the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle).'),
3229
+ files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps. [import] OKF bundle files inline — skills/<slug>/SKILL.md dirs and Skill/Playbook/SOP/Procedure-typed .md with name + description frontmatter become org skills. Caps: 500 files, 512KB/file, 5MB total.'),
3230
+ dir: z.string().optional().describe('[push|export|import] local directory. push: source tree to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces; the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle). export: write the OKF bundle files here (default ./okf-skills-<org>). import: read the bundle from here (alternative to files[]).'),
3145
3231
  deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
3232
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
3233
+ includeGlobal: z.boolean().optional().describe('[export] include global built-in skills in the bundle (default: org skills only)'),
3234
+ dryRun: z.boolean().optional().describe('[import] report {created, updated, skips, warnings} without writing'),
3146
3235
  }, async (args) => {
3147
3236
  try {
3148
3237
  const { action } = args;
@@ -3150,7 +3239,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3150
3239
  // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
3151
3240
  // UUID-first exception: update/remove addressed by a skillId derive their org
3152
3241
  // from the skill row server-side, so the bound-org gate is unnecessary.
3153
- if (['add', 'update', 'remove', 'push', 'fork'].includes(action)) {
3242
+ if (['add', 'update', 'remove', 'push', 'fork', 'import'].includes(action)) {
3154
3243
  const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
3155
3244
  if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
3156
3245
  }
@@ -3349,6 +3438,64 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3349
3438
  if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
3350
3439
  return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
3351
3440
  }
3441
+
3442
+ // ── export ──────────────────────────────────────────────────
3443
+ // The skill library as an OKF v0.1 bundle (skills/<slug>/SKILL.md +
3444
+ // supporting files + synthesized log.md/index.md). Mirrors wiki export:
3445
+ // format="files" pages the bundle inline; otherwise stdio writes a local
3446
+ // dir, remote returns the authenticated tar.gz download URL.
3447
+ case 'export': {
3448
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3449
+ const inc = args.includeGlobal ? '&includeGlobal=1' : '';
3450
+ if (args.format === 'files') {
3451
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3452
+ if (args.offset) qp.set('offset', String(args.offset));
3453
+ if (args.compact) qp.set('compact', 'true');
3454
+ return ok(await api('GET', `/api/skills/export?${qp.toString()}${inc}`, undefined, extra));
3455
+ }
3456
+ if (isRemote) {
3457
+ return ok({
3458
+ downloadUrl: `${getServerUrl()}/api/skills/export.tar.gz${args.includeGlobal ? '?includeGlobal=1' : ''}`,
3459
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 skill bundle, or call export with format="files" to page the bundle contents inline.',
3460
+ });
3461
+ }
3462
+ // stdio: write every bundle file under a local directory.
3463
+ const skOrgCtx = await getCurrentOrgContext();
3464
+ const skOrgSlug = String(skOrgCtx?.name || skOrgCtx?.id || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3465
+ const exportDir = resolve(args.dir || `./okf-skills-${skOrgSlug}`);
3466
+ let expOffset = 0;
3467
+ let written = 0;
3468
+ for (;;) {
3469
+ const batch = await api('GET', `/api/skills/export?limit=200&offset=${expOffset}${inc}`, undefined, extra);
3470
+ const batchFiles = batch.files || [];
3471
+ for (const f of batchFiles) {
3472
+ const dest = resolve(exportDir, f.path);
3473
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3474
+ mkdirSync(dirname(dest), { recursive: true });
3475
+ writeFileSync(dest, f.content, 'utf8');
3476
+ written++;
3477
+ }
3478
+ expOffset += batchFiles.length;
3479
+ if (!batch.truncated || batchFiles.length === 0) break;
3480
+ }
3481
+ return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 skill bundle written (skills/<slug>/SKILL.md layout; index.md and log.md are synthesized).' });
3482
+ }
3483
+
3484
+ // ── import ──────────────────────────────────────────────────
3485
+ // Ingest the SKILL.md-shaped concepts of an OKF bundle as org skills:
3486
+ // inline files[] or (stdio) a local dir walked with the push filters.
3487
+ // Use wiki(action="import") for a mixed knowledge bundle — it routes
3488
+ // skill concepts here and everything else into the wiki.
3489
+ case 'import': {
3490
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3491
+ let importFiles = args.files;
3492
+ if (!importFiles && args.dir) importFiles = collectSkillTreeForPush(args.dir);
3493
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3494
+ throw new Error('import requires files[] (or dir on stdio) with at least one file');
3495
+ }
3496
+ return ok(await api('POST', '/api/skills/import', { files: importFiles, dryRun: !!args.dryRun }, extra));
3497
+ }
3498
+
3352
3499
  default:
3353
3500
  throw new Error(`Unknown skill action: ${action}`);
3354
3501
  }
@@ -3359,20 +3506,32 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3359
3506
  // All 11 actions dispatch from one tool. Read-only actions skip the
3360
3507
  // skill gate; mutations require org-level wiki-maintainer skills loaded.
3361
3508
 
3362
- tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry.\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
3363
- action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'source-register', 'source-list', 'source-get', 'bulk-write']).describe('Operation to perform.'),
3364
- path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
3509
+ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry. Reference sources with `cite` (appends a numbered entry to the page\'s `# Citations` section).\n\nThe wiki is an OKF v0.1 bundle (Open Knowledge Format): every page carries frontmatter with a `type` (default "Page"), a one-line `description` is recommended, links may use `/a/b.md` or extensionless `a/b` form, and `index.md` at any level is synthesized — read it for a directory listing, never write it. Exchange whole bundles with `export` (conformant tar.gz / local dir / paged files) and `import` (files[] or local dir, dryRun supported).\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
3510
+ action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'cite', 'source-register', 'source-list', 'source-get', 'bulk-write', 'export', 'import']).describe('Operation to perform. export: the whole wiki as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest an OKF bundle (files[] or local dir; dryRun supported).'),
3511
+ path: z.string().optional().describe('[ls|read|links|cite] wiki path. For ls: default / (root). For read: required. For links/cite: required unless pageId given. Reading `index.md` (any level) returns the SYNTHESIZED OKF directory listing.'),
3365
3512
  pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
3366
3513
  org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched). [write] the org the page is created in — required when you belong to several orgs and no project is open. [search] restrict to this org (default: ALL your orgs). [ls|recent|read|links|log|health|edit|mv|rm|bulk-write] target this org\'s wiki instead of the open project\'s org. Ignored when a pageId is given (the page self-derives its org).'),
3367
3514
  recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
3368
- limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
3515
+ limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
3516
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
3517
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
3518
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
3519
+ files: z.array(z.object({
3520
+ path: z.string().describe('Bundle-relative file path, e.g. "concepts/frames.md"'),
3521
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
3522
+ })).optional().describe('[import] OKF bundle files inline. index.md files are skipped (synthesized), bundle-root log.md merges into the wiki log page. Caps: 500 files, 512KB/file, 5MB total.'),
3523
+ ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-export-<org>). import: recursively read .md files from here (alternative to files[]).') }),
3369
3524
  query: z.string().optional().describe('[search] term to search in title, path, and content'),
3370
3525
  lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
3371
- message: z.string().optional().describe('[log] message to append to log.md page'),
3526
+ message: z.string().optional().describe('[log] message to append to the log page (OKF date-grouped format)'),
3527
+ verb: z.string().optional().describe('[log] leading bold verb for the entry: Update (default), Creation, Deprecation, or Initialization.'),
3372
3528
  title: z.string().optional().describe('[write] page title (required for write)'),
3373
3529
  content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
3374
- type: z.string().optional().describe('[write] page type (default "page")'),
3375
- frontmatter: z.any().optional().describe('[write] frontmatter object'),
3530
+ type: z.string().optional().describe('[write] OKF concept type — free-form string, e.g. "Page", "Reference", "Playbook", "Decision", "Metric". Defaults to "Page" on create; must stay non-empty on update (OKF v0.1 requires a type on every page).'),
3531
+ raw: z.boolean().optional().describe('[read] return content bytes-as-stored (skip the synthesized OKF frontmatter block). Do NOT build edit operations from a raw read — hashline anchors for edit must come from a normal (non-raw) read.'),
3532
+ url: z.string().optional().describe('[cite] citation URL (required for cite)'),
3533
+ label: z.string().optional().describe('[cite] link label for the citation (defaults to the URL)'),
3534
+ frontmatter: z.any().optional().describe('[write] frontmatter object. Recommended OKF keys: a one-line "description" (index listings and consumers read it), "tags" (list), "resource" (canonical URI of the underlying asset). Unknown keys are preserved; never set "timestamp" (synthesized from the last update).'),
3376
3535
  operations: z.array(z.object({
3377
3536
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
3378
3537
  lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
@@ -3380,10 +3539,10 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3380
3539
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3381
3540
  from: z.string().optional().describe('[mv] source path'),
3382
3541
  to: z.string().optional().describe('[mv] destination path'),
3383
- dryRun: z.boolean().optional().describe('[mv|rm] preview impact without applying changes'),
3542
+ dryRun: z.boolean().optional().describe('[mv|rm|import] preview impact without applying changes (import: returns the {creates, updates, skips, warnings} report without writing)'),
3384
3543
  file_path: z.string().optional().describe('[source-register] absolute path to a local file. Server hashes it and registers the source. stdio MCP only.'),
3385
- contentHash: z.string().optional().describe('[source-register|source-list] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP).'),
3386
- filename: z.string().optional().describe('[source-register] original filename for the source (informational)'),
3544
+ contentHash: z.string().optional().describe('[source-register|source-list|cite] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP). For cite: also registers the cited source.'),
3545
+ filename: z.string().optional().describe('[source-register|cite] original filename for the source (informational)'),
3387
3546
  contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
3388
3547
  size: z.number().optional().describe('[source-register] byte size (informational)'),
3389
3548
  sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
@@ -3398,7 +3557,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3398
3557
  // Otherwise (path-addressed, multi-org, nothing bound) the gate still
3399
3558
  // refuses to guess the org so a write never silently lands in the wrong one.
3400
3559
  const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
3401
- if (['write', 'edit', 'mv', 'rm', 'bulk-write'].includes(action)) {
3560
+ if (['write', 'edit', 'mv', 'rm', 'bulk-write', 'cite', 'import'].includes(action)) {
3402
3561
  if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
3403
3562
  }
3404
3563
 
@@ -3427,7 +3586,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3427
3586
  // Ensure the wiki-maintainer skill is attached to this org BEFORE the
3428
3587
  // gate check, so the gate fires reliably on the very first wiki call —
3429
3588
  // not just after the org has visited /wiki in a browser. Idempotent.
3430
- const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'source-register', 'bulk-write']);
3589
+ const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'cite', 'source-register', 'bulk-write', 'import']);
3431
3590
  if (MUTATING.has(action)) {
3432
3591
  try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
3433
3592
  const skillErr = await checkOrgSkills(orgId, action);
@@ -3510,19 +3669,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3510
3669
  // Returns content in hashline format (`LINE+ID|content`) so the
3511
3670
  // agent can produce hashline edit operations. Mirrors frame.read.
3512
3671
  case 'read': {
3513
- const { path: readPath, pageId: readPageId, lines: readLines } = args;
3672
+ const { path: readPath, pageId: readPageId, lines: readLines, raw: readRaw } = args;
3514
3673
  if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
3515
3674
  let page;
3516
3675
  if (readPageId) {
3517
3676
  // UUID-first: address the page directly, org auto-derives server-side.
3518
3677
  const params = new URLSearchParams({ format: 'hashline' });
3519
3678
  if (readLines) params.set('lines', readLines);
3679
+ if (readRaw) params.set('raw', 'true');
3520
3680
  page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
3521
3681
  } else {
3522
3682
  if (!readPath) throw new Error('path or pageId required for action=read');
3523
3683
  const normalized = normalizeWikiPath(readPath);
3524
3684
  const params = new URLSearchParams({ path: normalized, format: 'hashline' });
3525
3685
  if (readLines) params.set('lines', readLines);
3686
+ if (readRaw) params.set('raw', 'true');
3526
3687
  page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
3527
3688
  }
3528
3689
  // Get backlink count via search (approximate)
@@ -3582,41 +3743,156 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3582
3743
  }
3583
3744
 
3584
3745
  // ── log ─────────────────────────────────────────────────────
3746
+ // OKF log.md format: newest-first `## YYYY-MM-DD` date headings (UTC),
3747
+ // each with `* **Verb**: message (agent, HH:MM UTC)` bullets. Legacy
3748
+ // `## <ISO datetime> ...` headings on existing log pages are left as-is.
3585
3749
  case 'log': {
3586
- const { message: logMessage } = args;
3750
+ const { message: logMessage, verb: logVerb } = args;
3587
3751
  if (!logMessage) throw new Error('message required for action=log');
3588
3752
  const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
3589
- const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z';
3590
- const entry = `## ${dateStr} note by ${agentName} | ${logMessage}`;
3753
+ const now = new Date();
3754
+ const entry = formatOkfLogEntry(logVerb, logMessage, agentName, now);
3755
+ const logTitle = (orgCtx?.name ? orgCtx.name + ' ' : '') + 'Log';
3591
3756
 
3592
- // Try to read existing log page
3593
- let existingContent = '';
3594
- let existingId = null;
3757
+ // Try to read existing log page (raw: bytes-as-stored, no synthesis)
3758
+ let logPage = null;
3595
3759
  try {
3596
- const logPage = await api('GET', '/api/wiki/page?path=log', undefined, orgHeader);
3597
- existingContent = logPage.content || '';
3598
- existingId = logPage.id;
3760
+ logPage = await api('GET', '/api/wiki/page?path=log&raw=true', undefined, orgHeader);
3599
3761
  } catch {
3600
3762
  // Create new log page
3601
3763
  const created = await api('POST', '/api/wiki/pages', {
3602
3764
  path: 'log',
3603
3765
  title: 'Log',
3604
- content: entry + '\n',
3766
+ type: 'Log',
3767
+ content: appendOkfLogEntry('', entry, now, logTitle),
3605
3768
  }, orgHeader);
3606
3769
  return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
3607
3770
  }
3608
3771
 
3609
- // Append to existing log
3610
- const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
3611
- await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
3772
+ // Append under today's date heading (created at the top if missing)
3773
+ const updatedContent = appendOkfLogEntry(logPage.content || '', entry, now, logTitle);
3774
+ await api('PATCH', `/api/wiki/pages/${logPage.id}`, { content: updatedContent }, orgHeader);
3612
3775
  return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
3613
3776
  }
3614
3777
 
3778
+ // ── cite ────────────────────────────────────────────────────
3779
+ // Append a numbered citation to a page's `# Citations` section
3780
+ // (creating the section if missing), OKF style: `[n] [label](url)`.
3781
+ // Optionally registers a wiki source when contentHash is given.
3782
+ case 'cite': {
3783
+ const { path: citePath, pageId: citePageId, url: citeUrl, label: citeLabel, contentHash: citeHash, filename: citeFilename } = args;
3784
+ if (!citeUrl) throw new Error('url required for action=cite');
3785
+ let page;
3786
+ if (citePageId) {
3787
+ page = await api('GET', `/api/wiki/pages/${citePageId}?raw=true`);
3788
+ } else {
3789
+ if (!citePath) throw new Error('path or pageId required for action=cite');
3790
+ page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(citePath))}&raw=true`, undefined, orgHeader);
3791
+ }
3792
+ const lines = (page.content || '').split('\n');
3793
+ let maxN = 0;
3794
+ for (const l of lines) {
3795
+ const m = l.match(/^\[(\d+)\]\s/);
3796
+ if (m) maxN = Math.max(maxN, parseInt(m[1], 10));
3797
+ }
3798
+ const n = maxN + 1;
3799
+ const entry = `[${n}] [${citeLabel || citeUrl}](${citeUrl})`;
3800
+ let content;
3801
+ const hIdx = lines.findIndex((l) => /^#{1,3}\s+Citations\s*$/.test(l));
3802
+ if (hIdx < 0) {
3803
+ const base = (page.content || '').replace(/\s+$/, '');
3804
+ content = (base ? base + '\n\n' : '') + '# Citations\n\n' + entry + '\n';
3805
+ } else {
3806
+ let end = hIdx + 1;
3807
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
3808
+ let insertAt = end;
3809
+ while (insertAt > hIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
3810
+ lines.splice(insertAt, 0, entry);
3811
+ content = lines.join('\n');
3812
+ }
3813
+ await api('PATCH', `/api/wiki/pages/${page.id}`, { content });
3814
+ let source = null;
3815
+ if (citeHash) {
3816
+ try {
3817
+ source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
3818
+ } catch { /* source registration is best-effort */ }
3819
+ }
3820
+ return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path) }));
3821
+ }
3822
+
3615
3823
  // ── health ──────────────────────────────────────────────────
3616
3824
  case 'health': {
3617
3825
  return ok(await api('GET', '/api/wiki/health', undefined, orgHeader));
3618
3826
  }
3619
3827
 
3828
+ // ── export ──────────────────────────────────────────────────
3829
+ // The whole wiki as an OKF v0.1 bundle. format="files" pages the bundle
3830
+ // inline; otherwise stdio writes a local dir, remote returns the
3831
+ // authenticated tar.gz download URL.
3832
+ case 'export': {
3833
+ if (args.format === 'files') {
3834
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3835
+ if (args.offset) qp.set('offset', String(args.offset));
3836
+ if (args.compact) qp.set('compact', 'true');
3837
+ return ok(withOrg(await api('GET', `/api/wiki/export?${qp.toString()}`, undefined, orgHeader)));
3838
+ }
3839
+ if (isRemote) {
3840
+ return ok(withOrg({
3841
+ downloadUrl: `${getServerUrl()}/api/wiki/export.tar.gz`,
3842
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 bundle, or call export with format="files" to page the bundle contents inline.',
3843
+ }));
3844
+ }
3845
+ // stdio: write every bundle file under a local directory.
3846
+ const orgSlug = String(orgCtx?.name || orgId || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3847
+ const exportDir = resolve(args.dir || `./okf-export-${orgSlug}`);
3848
+ let expOffset = 0;
3849
+ let written = 0;
3850
+ for (;;) {
3851
+ const batch = await api('GET', `/api/wiki/export?limit=200&offset=${expOffset}`, undefined, orgHeader);
3852
+ const batchFiles = batch.files || [];
3853
+ for (const f of batchFiles) {
3854
+ const dest = resolve(exportDir, f.path);
3855
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3856
+ mkdirSync(dirname(dest), { recursive: true });
3857
+ writeFileSync(dest, f.content, 'utf8');
3858
+ written++;
3859
+ }
3860
+ expOffset += batchFiles.length;
3861
+ if (!batch.truncated || batchFiles.length === 0) break;
3862
+ }
3863
+ return ok(withOrg({ exported: written, dir: exportDir, note: 'OKF v0.1 bundle written. Pages carry synthesized frontmatter; index.md files are synthesized directory listings.' }));
3864
+ }
3865
+
3866
+ // ── import ──────────────────────────────────────────────────
3867
+ // Ingest an OKF bundle: inline files[] or (stdio) a local dir walked
3868
+ // for .md files. index.md skipped, bundle-root log.md merged into the
3869
+ // wiki log page, frontmatter lifted with unknown keys preserved.
3870
+ case 'import': {
3871
+ let importFiles = args.files;
3872
+ if (!importFiles && args.dir) {
3873
+ const root = resolve(args.dir);
3874
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
3875
+ importFiles = [];
3876
+ const walk = (d) => {
3877
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
3878
+ if (ent.name.startsWith('.')) continue;
3879
+ const p = join(d, ent.name);
3880
+ if (ent.isDirectory()) walk(p);
3881
+ else if (/\.md$/i.test(ent.name)) {
3882
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
3883
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
3884
+ }
3885
+ }
3886
+ };
3887
+ walk(root);
3888
+ }
3889
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3890
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
3891
+ }
3892
+ const result = await api('POST', '/api/wiki/import', { files: importFiles, dryRun: !!args.dryRun }, orgHeader);
3893
+ return ok(withOrg(result));
3894
+ }
3895
+
3620
3896
  // ── write ───────────────────────────────────────────────────
3621
3897
  case 'write': {
3622
3898
  const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
@@ -3628,15 +3904,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3628
3904
  if (writeType) body.type = writeType;
3629
3905
  if (frontmatter !== undefined) body.frontmatter = frontmatter;
3630
3906
 
3907
+ // Writing the reserved log page directly is allowed (it IS an editable
3908
+ // page) but the `log` action keeps the OKF date-grouped format for you.
3909
+ const logNote = normalized === 'log'
3910
+ ? 'Note: prefer wiki(action="log") for log entries — it maintains the OKF date-grouped format (## YYYY-MM-DD headings, newest first).'
3911
+ : undefined;
3912
+
3631
3913
  // Check if page exists — if so, update; otherwise create. `orgHeader`
3632
3914
  // (the `org` arg) targets a specific org without switching the active org.
3633
3915
  try {
3634
3916
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
3635
3917
  const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
3636
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
3918
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path), note: logNote }));
3637
3919
  } catch {
3638
3920
  const result = await api('POST', '/api/wiki/pages', body, orgHeader);
3639
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
3921
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path), note: logNote }));
3640
3922
  }
3641
3923
  }
3642
3924
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.12.8",
3
+ "version": "1.13.0",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,62 @@
1
+ /**
2
+ * OKF v0.1 log.md formatting (knowledge-catalog okf/SPEC.md, reserved files):
3
+ * a `# ... Log` title, then newest-first `## YYYY-MM-DD` date headings (ISO
4
+ * date ONLY), each with `* **Verb**: message` bullets.
5
+ *
6
+ * Lives in src/shared (shipped in the npm package) because both the server
7
+ * (server/lib/okf.mjs) and the stdio MCP wiki `log` action need it —
8
+ * server/lib is NOT shipped to npm installs.
9
+ */
10
+
11
+ const OKF_LOG_VERBS = ['Update', 'Creation', 'Deprecation', 'Initialization'];
12
+
13
+ /** Normalize a verb: capitalized, defaulting to 'Update'. Unknown verbs pass
14
+ * through capitalized — the leading bold word is an OKF convention, not an
15
+ * enforced enum. */
16
+ export function okfLogVerb(verb) {
17
+ const v = typeof verb === 'string' ? verb.trim() : '';
18
+ if (!v) return 'Update';
19
+ return v[0].toUpperCase() + v.slice(1);
20
+ }
21
+
22
+ /** One OKF log bullet: `* **Verb**: message (agent, HH:MM UTC)` */
23
+ export function formatOkfLogEntry(verb, message, agent, when = new Date()) {
24
+ const hh = String(when.getUTCHours()).padStart(2, '0');
25
+ const mm = String(when.getUTCMinutes()).padStart(2, '0');
26
+ return `* **${okfLogVerb(verb)}**: ${message} (${agent}, ${hh}:${mm} UTC)`;
27
+ }
28
+
29
+ /**
30
+ * Insert `entryLine` under the `## YYYY-MM-DD` heading for `when` (UTC),
31
+ * creating the heading (newest-first, after the `# ... Log` title when one
32
+ * exists) if missing. Legacy `## <ISO datetime> ...` headings are left
33
+ * untouched — permissive, no destructive rewrite.
34
+ */
35
+ export function appendOkfLogEntry(content, entryLine, when = new Date(), title = 'Log') {
36
+ const dateHeading = '## ' + when.toISOString().slice(0, 10);
37
+ const text = content || '';
38
+ if (!text.trim()) {
39
+ return ['# ' + title, '', dateHeading, '', entryLine, ''].join('\n');
40
+ }
41
+ const lines = text.split('\n');
42
+ const idx = lines.findIndex((l) => l.trim() === dateHeading);
43
+ if (idx >= 0) {
44
+ // Append at the end of today's section (before the next heading),
45
+ // skipping past trailing blank lines inside the section.
46
+ let end = idx + 1;
47
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
48
+ let insertAt = end;
49
+ while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
50
+ lines.splice(insertAt, 0, entryLine);
51
+ return lines.join('\n');
52
+ }
53
+ // New date section goes at the top (newest first), after a leading
54
+ // `# ...` document title when present.
55
+ let at = 0;
56
+ if (/^#\s/.test(lines[0])) {
57
+ at = 1;
58
+ while (at < lines.length && lines[at].trim() === '') at++;
59
+ }
60
+ lines.splice(at, 0, dateHeading, '', entryLine, '');
61
+ return lines.join('\n');
62
+ }