drafted 1.11.12 → 1.11.14

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.
@@ -29,6 +29,8 @@ Skill rules:
29
29
  - Search skills before starting repeatable work: skill(action="search") or skill(action="list").
30
30
  - Load and follow relevant skills with skill(action="load"). Read supporting skill files when needed.
31
31
  - When the user asks to record, distill, create, save, install, or update a skill/procedure/SOP/checklist/method/protocol/playbook, create or update a Drafted skill for the org with skill tools.
32
+ - Every skill has a portable part — its method, SKILL.md, and any script source — and that part always belongs in Drafted, not only in a local repo. When you build a runnable skill, push the source to Drafted (skill action="push", or skill action="add" for prose) and declare how to rebuild it in the skill's `setup:` frontmatter (e.g. ["npm ci"]) so any machine or agent can regenerate it. Do not leave a reusable skill authored only as local files when Drafted is connected.
33
+ - Machine-specific build output (node_modules, downloaded browsers, compiled binaries) is never portable: build it into a `.skillinstall/` directory inside the skill. Drafted always strips `.skillinstall/` from a pushed bundle and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe stay in Drafted.
32
34
  - Improve skills when you find a better checklist, standard, or operating method.
33
35
 
34
36
  Project/producible rules:
package/cli/drafted.mjs CHANGED
@@ -1828,7 +1828,7 @@ skillCmd
1828
1828
  function collectSkillTree(dir) {
1829
1829
  const root = resolve(dir);
1830
1830
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
1831
- const SKIP_DIRS = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
1831
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
1832
1832
  let ignore = () => false;
1833
1833
  const ignPath = join(root, '.skillignore');
1834
1834
  if (existsSync(ignPath)) {
@@ -1854,9 +1854,25 @@ function collectSkillTree(dir) {
1854
1854
  return out;
1855
1855
  }
1856
1856
 
1857
+ // Ensure the pushed source tree's .gitignore excludes the rebuildable bundle dir,
1858
+ // so a skill's machine-specific build output (built into .skillinstall/ by its
1859
+ // `setup:` recipe) can never be committed. The server already strips .skillinstall/
1860
+ // from the bundle (skill-ingest DENY_DIRS) — this keeps the author's git clean too.
1861
+ // Idempotent; returns true only when it adds the entry.
1862
+ function ensureSkillInstallIgnored(dir) {
1863
+ try {
1864
+ const gi = join(dir, '.gitignore');
1865
+ const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
1866
+ if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
1867
+ const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
1868
+ writeFileSync(gi, body + '.skillinstall/\n');
1869
+ return true;
1870
+ } catch { return false; }
1871
+ }
1872
+
1857
1873
  skillCmd
1858
1874
  .command('push')
1859
- .description('Push a local source tree into a skill (bulk ingest; server strips artifacts + enforces caps)')
1875
+ .description('Push a local source tree into a Drafted skill (bulk ingest). Stores the portable bits — SKILL.md, scripts, and the setup: recipe; build output is stripped server-side. Build machine-specific output into .skillinstall/ (auto-gitignored, always stripped) and declare its rebuild in setup:.')
1860
1876
  .option('--id <id>', 'skill id')
1861
1877
  .option('--slug <slug>', 'skill slug (resolved to id)')
1862
1878
  .requiredOption('--dir <dir>', 'local source tree to push')
@@ -1878,8 +1894,137 @@ skillCmd
1878
1894
  });
1879
1895
  const data = await res.json().catch(() => ({}));
1880
1896
  if (!res.ok) { emitSkillResult(opts.format, { status: 'error', id, error: data.error || `HTTP ${res.status}` }); process.exit(1); }
1881
- if (opts.format === 'json') console.log(JSON.stringify({ status: 'ok', id, hash: data.hash, count: data.count, stripped: data.stripped, files: data.files }));
1882
- else console.log(`ok\t${id}\t${data.hash}\tpushed ${data.count}${data.stripped ? `, stripped ${data.stripped}` : ''}`);
1897
+ const gitignored = ensureSkillInstallIgnored(opts.dir);
1898
+ if (opts.format === 'json') console.log(JSON.stringify({ status: 'ok', id, hash: data.hash, count: data.count, stripped: data.stripped, gitignored, files: data.files }));
1899
+ else console.log(`ok\t${id}\t${data.hash}\tpushed ${data.count}${data.stripped ? `, stripped ${data.stripped}` : ''}${gitignored ? ', gitignored .skillinstall/' : ''}`);
1900
+ });
1901
+
1902
+ // ── Collectors: management seam (parity with the MCP `collector` tool) ──
1903
+ // Collectors are checklist-driven intake surfaces. Management is behind the
1904
+ // agent allowlist (same gate as Minion) and scoped to the session's active org.
1905
+ function emitCollectorResult(format, obj) {
1906
+ if (format === 'json') { console.log(JSON.stringify(obj)); return; }
1907
+ console.log([obj.status, obj.id || '', obj.slug || '', obj.name || '', obj.error || ''].join('\t'));
1908
+ }
1909
+
1910
+ async function collectorSetEnabled(id, enabled, format) {
1911
+ requireLogin();
1912
+ const server = getServerUrl().replace(/\/$/, '');
1913
+ const res = await authFetch(`${server}/api/collectors/${encodeURIComponent(id)}`, {
1914
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
1915
+ });
1916
+ const data = await res.json().catch(() => ({}));
1917
+ if (!res.ok) { emitCollectorResult(format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
1918
+ const c = data.collector || {};
1919
+ emitCollectorResult(format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
1920
+ }
1921
+
1922
+ const collectorCmd = program.command('collector').description('Collector management (checklist-driven intake surfaces). Requires the agent allowlist.');
1923
+
1924
+ collectorCmd
1925
+ .command('list')
1926
+ .description('List collectors (scoped to --project or your active project; all org collectors if neither)')
1927
+ .option('--project <id>', 'project to scope to (defaults to your active project)')
1928
+ .option('--format <fmt>', 'output format: json or text', 'text')
1929
+ .action(async (opts) => {
1930
+ const pid = opts.project || getActiveProject()?.id;
1931
+ const data = await readApiGet('collector:list', withQuery('/api/collectors', { projectId: pid }));
1932
+ const rows = data.collectors || [];
1933
+ if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
1934
+ for (const c of rows) console.log(`${c.id}\t${c.slug}\t${c.enabled ? 'enabled' : 'disabled'}\t${c.name}`);
1935
+ });
1936
+
1937
+ collectorCmd
1938
+ .command('get <id>')
1939
+ .description('Get a collector by id')
1940
+ .option('--format <fmt>', 'output format: json or text', 'text')
1941
+ .action(async (id, opts) => {
1942
+ const data = await readApiGet('collector:get', `/api/collectors/${encodeURIComponent(id)}`);
1943
+ if (opts.format === 'json') { console.log(JSON.stringify(data.collector || data)); return; }
1944
+ const c = data.collector || {};
1945
+ console.log(`${c.id}\t${c.slug}\t${c.enabled ? 'enabled' : 'disabled'}\t${c.name}`);
1946
+ });
1947
+
1948
+ collectorCmd
1949
+ .command('meta')
1950
+ .description('Project layers/lanes/frames for building a collector target/output')
1951
+ .option('--project <id>', 'project (defaults to your active project)')
1952
+ .option('--format <fmt>', 'output format: json or text', 'text')
1953
+ .action(async (opts) => {
1954
+ const pid = opts.project || getActiveProject()?.id;
1955
+ if (!pid) { console.error('No active project. Pass --project <id> or run `drafted use <project>`.'); process.exit(1); }
1956
+ const data = await readApiGet('collector:meta', withQuery('/api/collectors/meta', { projectId: pid }));
1957
+ console.log(JSON.stringify(data));
1958
+ });
1959
+
1960
+ collectorCmd
1961
+ .command('create')
1962
+ .description('Create a collector from stdin JSON {name,description?,target,checklist,output,enabled?}')
1963
+ .option('--project <id>', 'project to bind to (defaults to your active project)')
1964
+ .option('--format <fmt>', 'output format: json or text', 'text')
1965
+ .action(async (opts) => {
1966
+ requireLogin();
1967
+ const pid = opts.project || getActiveProject()?.id;
1968
+ if (!pid) { emitCollectorResult(opts.format, { status: 'error', error: 'no project — pass --project <id> or run `drafted use <project>`' }); process.exit(1); }
1969
+ let p;
1970
+ try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
1971
+ const server = getServerUrl().replace(/\/$/, '');
1972
+ const res = await authFetch(`${server}/api/collectors`, {
1973
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1974
+ body: JSON.stringify({ projectId: pid, name: p.name, description: p.description, target: p.target, checklist: p.checklist, output: p.output, enabled: p.enabled }),
1975
+ });
1976
+ const data = await res.json().catch(() => ({}));
1977
+ if (!res.ok) { emitCollectorResult(opts.format, { status: res.status === 403 ? 'forbidden' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
1978
+ const c = data.collector || {};
1979
+ emitCollectorResult(opts.format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
1980
+ });
1981
+
1982
+ collectorCmd
1983
+ .command('update <id>')
1984
+ .description('Update a collector from stdin JSON {name?,description?,target?,checklist?,output?,enabled?}')
1985
+ .option('--format <fmt>', 'output format: json or text', 'text')
1986
+ .action(async (id, opts) => {
1987
+ requireLogin();
1988
+ let p;
1989
+ try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
1990
+ const body = {};
1991
+ for (const k of ['name', 'description', 'target', 'checklist', 'output', 'enabled']) {
1992
+ if (p[k] !== undefined) body[k] = p[k];
1993
+ }
1994
+ if (Object.keys(body).length === 0) { emitCollectorResult(opts.format, { status: 'error', error: 'no fields to update' }); process.exit(1); }
1995
+ const server = getServerUrl().replace(/\/$/, '');
1996
+ const res = await authFetch(`${server}/api/collectors/${encodeURIComponent(id)}`, {
1997
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
1998
+ });
1999
+ const data = await res.json().catch(() => ({}));
2000
+ if (!res.ok) { emitCollectorResult(opts.format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
2001
+ const c = data.collector || {};
2002
+ emitCollectorResult(opts.format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
2003
+ });
2004
+
2005
+ collectorCmd
2006
+ .command('enable <id>')
2007
+ .description('Make a collector live')
2008
+ .option('--format <fmt>', 'output format: json or text', 'text')
2009
+ .action((id, opts) => collectorSetEnabled(id, true, opts.format));
2010
+
2011
+ collectorCmd
2012
+ .command('disable <id>')
2013
+ .description('Take a collector offline')
2014
+ .option('--format <fmt>', 'output format: json or text', 'text')
2015
+ .action((id, opts) => collectorSetEnabled(id, false, opts.format));
2016
+
2017
+ collectorCmd
2018
+ .command('delete <id>')
2019
+ .description('Delete a collector (past submissions are kept as history)')
2020
+ .option('--format <fmt>', 'output format: json or text', 'text')
2021
+ .action(async (id, opts) => {
2022
+ requireLogin();
2023
+ const server = getServerUrl().replace(/\/$/, '');
2024
+ const res = await authFetch(`${server}/api/collectors/${encodeURIComponent(id)}`, { method: 'DELETE' });
2025
+ const data = await res.json().catch(() => ({}));
2026
+ if (!res.ok) { emitCollectorResult(opts.format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
2027
+ emitCollectorResult(opts.format, { status: 'ok', id: data.id || id });
1883
2028
  });
1884
2029
 
1885
2030
  program.parse();
package/install-mcp.sh CHANGED
@@ -443,6 +443,8 @@ Skill rules:
443
443
  - Search skills before starting repeatable work: skill(action="search") or skill(action="list").
444
444
  - Load and follow relevant skills with skill(action="load"). Read supporting skill files when needed.
445
445
  - When the user asks to record, distill, create, save, install, or update a skill/procedure/SOP/checklist/method/protocol/playbook, create or update a Drafted skill for the org with skill tools.
446
+ - Every skill has a portable part — its method, SKILL.md, and any script source — and that part always belongs in Drafted, not only in a local repo. When you build a runnable skill, push the source to Drafted (skill action="push", or skill action="add" for prose) and declare how to rebuild it in the skill's `setup:` frontmatter (e.g. ["npm ci"]) so any machine or agent can regenerate it. Do not leave a reusable skill authored only as local files when Drafted is connected.
447
+ - Machine-specific build output (node_modules, downloaded browsers, compiled binaries) is never portable: build it into a `.skillinstall/` directory inside the skill. Drafted always strips `.skillinstall/` from a pushed bundle and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe stay in Drafted.
446
448
  - Improve skills when you find a better checklist, standard, or operating method.
447
449
 
448
450
  Project/producible rules:
package/mcp/server.mjs CHANGED
@@ -139,6 +139,7 @@ const REMOTE_JSON_STRING_PARAMS = {
139
139
  wiki: ['frontmatter', 'pages'],
140
140
  project: ['layers'],
141
141
  template: ['layers'],
142
+ collector: ['target', 'checklist', 'output'],
142
143
  };
143
144
 
144
145
  // Remove sentences that reference local-file params from a tool description,
@@ -242,6 +243,9 @@ const TOOL_ANNOTATIONS = {
242
243
  // Skills
243
244
  skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
244
245
  wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
246
+
247
+ // Collectors — checklist-driven, Minion-run intake surfaces bound to a project
248
+ collector: { title: 'Collectors', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Collectors: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and then write a producible into the project. Dispatch by `action`: meta (discover layers/lanes/frames), list, get, create, update, enable, disable, delete. Requires the agent allowlist (same gate as Minion).' },
245
249
  };
246
250
 
247
251
  function isMutatingToolCall(name, args = {}) {
@@ -267,6 +271,8 @@ function isMutatingToolCall(name, args = {}) {
267
271
  return ['add', 'update', 'remove', 'attach', 'detach', 'favorite', 'unfavorite', 'update_file'].includes(action);
268
272
  case 'wiki':
269
273
  return ['log', 'write', 'edit', 'mv', 'rm', 'source-register', 'bulk-write'].includes(action);
274
+ case 'collector':
275
+ return ['create', 'update', 'enable', 'disable', 'delete'].includes(action);
270
276
  case 'rm':
271
277
  case 'shape':
272
278
  case 'group':
@@ -361,7 +367,7 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
361
367
  const v = args[0][k];
362
368
  if (typeof v === 'string' && v.trim()) {
363
369
  try { args[0][k] = JSON.parse(v); }
364
- catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${k === 'excalidraw_data' || k === 'frontmatter' ? 'object' : 'array'} string; JSON.parse failed.`)); }
370
+ catch { return err(new Error(`Param "${k}" must be a JSON-encoded ${['excalidraw_data', 'frontmatter', 'target', 'output'].includes(k) ? 'object' : 'array'} string; JSON.parse failed.`)); }
365
371
  }
366
372
  }
367
373
  }
@@ -2751,7 +2757,7 @@ tool('layout', 'Auto-arrange frames using graph layout algorithm. Positions conn
2751
2757
  function collectSkillTreeForPush(dir) {
2752
2758
  const root = resolve(dir);
2753
2759
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`not a directory: ${dir}`);
2754
- const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform']);
2760
+ const SKIP = new Set(['node_modules', '.git', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', 'coverage', '.cache', '.turbo', '.gradle', 'Pods', '.terraform', '.skillinstall']);
2755
2761
  const NUL = String.fromCharCode(0);
2756
2762
  let ignore = () => false;
2757
2763
  const ign = join(root, '.skillignore');
@@ -2778,7 +2784,22 @@ function collectSkillTreeForPush(dir) {
2778
2784
  return out;
2779
2785
  }
2780
2786
 
2781
- tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. 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.', {
2787
+ // Ensure a pushed source tree's .gitignore excludes the rebuildable bundle dir, so
2788
+ // a skill's machine-specific build output (built into .skillinstall/ by its `setup:`
2789
+ // recipe) can never be committed. The server also strips .skillinstall/ from the
2790
+ // bundle (skill-ingest DENY_DIRS); this keeps the author's git clean. Idempotent.
2791
+ function ensureSkillInstallIgnored(dir) {
2792
+ try {
2793
+ const gi = join(dir, '.gitignore');
2794
+ const existing = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
2795
+ if (existing.split(/\r?\n/).some((l) => l.trim().replace(/\/$/, '') === '.skillinstall')) return false;
2796
+ const body = existing && !existing.endsWith('\n') ? existing + '\n' : existing;
2797
+ writeFileSync(gi, body + '.skillinstall/\n');
2798
+ return true;
2799
+ } catch { return false; }
2800
+ }
2801
+
2802
+ 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.', {
2782
2803
  action: z.enum([
2783
2804
  'search', 'load', 'list', 'history',
2784
2805
  'add', 'update', 'remove',
@@ -2805,7 +2826,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2805
2826
  org: z.string().optional().describe('[fork|push|update] resolve/fork into this Drafted org (id or name); scopes the request without switching the session'),
2806
2827
  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"]'),
2807
2828
  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'),
2808
- dir: z.string().optional().describe('[push] local directory to push instead of files[]; walked locally (heavy dirs + .skillignore pre-filtered), server re-enforces'),
2829
+ 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).'),
2809
2830
  deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
2810
2831
  }, async (args) => {
2811
2832
  try {
@@ -2952,7 +2973,9 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2952
2973
  let fileList = files;
2953
2974
  if (!fileList && dir) fileList = collectSkillTreeForPush(dir);
2954
2975
  if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
2955
- return ok(await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra));
2976
+ const pushed = await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra);
2977
+ if (dir) { try { if (ensureSkillInstallIgnored(dir)) pushed.gitignored = '.skillinstall/'; } catch { /* best-effort */ } }
2978
+ return ok(pushed);
2956
2979
  }
2957
2980
  case 'attach': {
2958
2981
  const { skillId } = args;
@@ -3406,6 +3429,129 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3406
3429
  } catch (error) { return err(error); }
3407
3430
  });
3408
3431
 
3432
+ // ── Collectors ────────────────────────────────────────────────────
3433
+
3434
+ function compactCollectorEntry(c) {
3435
+ if (!c || typeof c !== 'object') return c;
3436
+ return { id: c.id, slug: c.slug, name: c.name, enabled: c.enabled, projectId: c.projectId };
3437
+ }
3438
+
3439
+ // Shape a {collectors:[...]} list with limit/offset pagination + optional compact
3440
+ // mode, mirroring shapeSkillCatalog so large lists stay within token budget.
3441
+ function shapeCollectorList(result, { limit, offset = 0, compact = false } = {}) {
3442
+ if (!Array.isArray(result?.collectors)) return result;
3443
+ const total = result.collectors.length;
3444
+ const start = Math.max(0, Math.floor(Number(offset) || 0));
3445
+ const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
3446
+ const page = result.collectors.slice(start, start + cap);
3447
+ result.totalAvailable = total;
3448
+ result.offset = start;
3449
+ result.returned = page.length;
3450
+ result.truncated = start + page.length < total;
3451
+ result.collectors = compact ? page.map(compactCollectorEntry) : page;
3452
+ result.note = compact
3453
+ ? 'Compact list: {id,slug,name,enabled,projectId} only. Use collector(action="get", id="<id>") for full config; limit/offset to page.'
3454
+ : 'Collectors are scoped to the active project (all org collectors when no project is open). Use limit/offset to page; compact=true for a leaner list.';
3455
+ return result;
3456
+ }
3457
+
3458
+ // Friendlier message when the agent allowlist gate (requireAgentAccess) rejects.
3459
+ function collectorGateError(e) {
3460
+ if (e?.status === 403 || e?.code === 'agent_disabled') {
3461
+ return new Error('Collector management is not enabled for this org/account (agent allowlist). Ask an admin to add your org or email to DRAFTED_AGENT_ALLOWED_ORGS / DRAFTED_AGENT_ALLOWED_EMAILS.');
3462
+ }
3463
+ return e;
3464
+ }
3465
+
3466
+ tool('collector', {
3467
+ action: z.enum(['meta', 'list', 'get', 'create', 'update', 'enable', 'disable', 'delete']).describe('Operation to perform.'),
3468
+ id: z.string().optional().describe('[get|update|enable|disable|delete] collector ID (UUID)'),
3469
+ projectId: z.string().optional().describe('[create|meta] project to bind/scope to (defaults to the active project). Operates on the session’s active org — open the target project first via project(action="open"), or get_org(action="switch") to change org.'),
3470
+ name: z.string().optional().describe('[create|update] collector name'),
3471
+ description: z.string().optional().describe('[create|update] one-line description shown to the consumer'),
3472
+ enabled: z.boolean().optional().describe('[create|update] whether the collector is live; a disabled collector 404s on its /c/<slug> link. enable/disable set this directly.'),
3473
+ target: z.object({
3474
+ type: z.enum(['new-record', 'layer', 'frame']).describe('what the run writes against'),
3475
+ layer: z.string().optional(),
3476
+ frameId: z.string().optional(),
3477
+ }).optional().describe('[create|update] where the run writes: a new record, into a layer, or updating a specific frame'),
3478
+ checklist: z.array(z.object({
3479
+ id: z.string().describe('stable item id'),
3480
+ label: z.string().describe('what the consumer is asked for'),
3481
+ description: z.string().optional(),
3482
+ evidence: z.enum(['text', 'file', 'photo', 'none']).optional().describe('evidence required (default none)'),
3483
+ required: z.boolean().optional(),
3484
+ })).optional().describe('[create|update] ordered checklist the Minion guides the consumer through'),
3485
+ output: z.object({
3486
+ mode: z.enum(['generate', 'update', 'annotate']).describe('how the producible is written'),
3487
+ layer: z.string().optional(),
3488
+ lane: z.string().optional(),
3489
+ filenameTemplate: z.string().optional().describe('e.g. "<id>.md"'),
3490
+ skillSlug: z.string().optional().describe('skill that shapes the produced frame'),
3491
+ grouping: z.string().optional().describe('"lane" gives each submission its own lane'),
3492
+ }).optional().describe('[create|update] where/how the producible lands'),
3493
+ limit: z.number().optional().describe('[list] max results per page (default 25, max 100)'),
3494
+ offset: z.number().optional().describe('[list] skip N results for pagination (default 0)'),
3495
+ compact: z.boolean().optional().describe('[list] return only {id,slug,name,enabled,projectId} per collector'),
3496
+ }, async (args) => {
3497
+ try {
3498
+ const { action } = args;
3499
+ switch (action) {
3500
+ case 'meta': {
3501
+ const active = getState().projectId;
3502
+ const pid = active || args.projectId;
3503
+ if (!pid) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
3504
+ // api() auto-appends the active project; add projectId explicitly only when none is active.
3505
+ const path = active ? '/api/collectors/meta' : `/api/collectors/meta?projectId=${encodeURIComponent(pid)}`;
3506
+ return ok(await api('GET', path));
3507
+ }
3508
+ case 'list': {
3509
+ // api() auto-appends the active project as ?projectId — so this lists the
3510
+ // active project's collectors, or all org collectors when none is open.
3511
+ const result = await api('GET', '/api/collectors');
3512
+ return ok(shapeCollectorList(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
3513
+ }
3514
+ case 'get': {
3515
+ if (!args.id) throw new Error('id is required for action=get');
3516
+ return ok(await api('GET', `/api/collectors/${args.id}`));
3517
+ }
3518
+ case 'create': {
3519
+ // projectId lives in the BODY (the POST route reads body, ignores the query).
3520
+ const projectId = args.projectId || getState().projectId;
3521
+ if (!projectId) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
3522
+ const { name, description, target, checklist, output, enabled } = args;
3523
+ if (!name || !target || !Array.isArray(checklist) || !output) {
3524
+ throw new Error('name, target, checklist[], and output are required for action=create');
3525
+ }
3526
+ const body = { projectId, name, target, checklist, output };
3527
+ if (description !== undefined) body.description = description;
3528
+ if (enabled !== undefined) body.enabled = enabled;
3529
+ return ok(await api('POST', '/api/collectors', body));
3530
+ }
3531
+ case 'update': {
3532
+ if (!args.id) throw new Error('id is required for action=update');
3533
+ const body = {};
3534
+ for (const k of ['name', 'description', 'target', 'checklist', 'output', 'enabled']) {
3535
+ if (args[k] !== undefined) body[k] = args[k];
3536
+ }
3537
+ if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
3538
+ return ok(await api('PATCH', `/api/collectors/${args.id}`, body));
3539
+ }
3540
+ case 'enable':
3541
+ case 'disable': {
3542
+ if (!args.id) throw new Error(`id is required for action=${action}`);
3543
+ return ok(await api('PATCH', `/api/collectors/${args.id}`, { enabled: action === 'enable' }));
3544
+ }
3545
+ case 'delete': {
3546
+ if (!args.id) throw new Error('id is required for action=delete');
3547
+ return ok(await api('DELETE', `/api/collectors/${args.id}`));
3548
+ }
3549
+ default:
3550
+ throw new Error(`Unknown collector action: ${action}`);
3551
+ }
3552
+ } catch (error) { return err(collectorGateError(error)); }
3553
+ });
3554
+
3409
3555
  // ── Resource: canvas info ─────────────────────────────────────────
3410
3556
 
3411
3557
  server.resource('info', 'drafted://info', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.11.12",
3
+ "version": "1.11.14",
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": [