drafted 1.19.16 → 1.19.17

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/cli/drafted.mjs CHANGED
@@ -16,7 +16,7 @@ import { homedir, tmpdir, platform } from 'os';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { DESIGN_SYSTEM_PROMPT, buildDesignPrompt } from './prompts.mjs';
19
- import { scanRepoAgents, detectDefaultBranch, detectRemoteUrl, isGitUrl, cloneToTemp } from './repo-scan.mjs';
19
+ import { scanRepoAgents, detectDefaultBranch, detectRemoteUrl, isGitUrl, cloneToTemp, classifySeed } from './repo-scan.mjs';
20
20
 
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = dirname(__filename);
@@ -2143,62 +2143,48 @@ minionCmd
2143
2143
  // read-only index for browse/search. `repo add` is idempotent (re-scan on re-add),
2144
2144
  // so content edits in git are reflected with no pin bump. No Drafted token in the
2145
2145
  // daemon — it shells out to this verb.
2146
- const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities)');
2146
+ // `repo add` and `repo remove` are gone on purpose. Linking a folder to a repo
2147
+ // (and detaching it) moves that folder's wiki + skills into git, makes Drafted
2148
+ // read-only for them, and commits someone's knowledge to someone's repository.
2149
+ // That is a decision a person makes in the Drafted UI — Settings > Organization
2150
+ // > GitHub to connect the account, then the folder menu — not something a script
2151
+ // or an agent does in passing. `list`, `lookup` and `rescan` remain.
2152
+ const repoCmd = program.command('repo').description('Registered git repos (org index of .agents/ skills/identities and .wiki/ pages)');
2147
2153
 
2148
2154
  repoCmd
2149
- .command('add <url>')
2150
- .description('Link a GitHub repo to an org FOLDER the server fetches it and indexes .agents/ skills/identities (repos are folder-scoped, never org-level). --branch points at a named branch (fails loudly if missing instead of guessing).')
2155
+ .command('list')
2156
+ .description('List the org\'s linked repos with their folder + git index counts (.agents/ skills/identities, .wiki/ pages)')
2151
2157
  .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2152
- .option('--folder <name>', 'folder to link the repo into (required)')
2153
- .option('--slug <slug>', 'repo slug (defaults to the repo name from the URL)')
2154
- .option('--branch <name>', 'git branch to track (default: the repo default; fails loudly if the branch is missing)')
2155
- .option('--description <desc>', 'short description')
2156
2158
  .option('--format <fmt>', 'output format: json or text', 'text')
2157
- .action(async (url, opts) => {
2158
- requireLogin();
2159
- const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
2160
- const server = getServerUrl().replace(/\/$/, '');
2161
- const fail = (msg) => { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:add', error: msg })); else console.log(['error', msg].join('\t')); process.exit(1); };
2162
- const folderName = String(opts.folder || '').trim();
2163
- if (!folderName) return fail('--folder <name> is required — repos are linked to a folder, never to the org directly');
2164
- // Resolve folder name -> id in the target org.
2165
- const foldersRes = await authFetch(`${server}/api/folders`, { headers: orgHeaders });
2166
- const foldersJson = await foldersRes.json().catch(() => ({}));
2167
- if (!foldersRes.ok) return fail(foldersJson.error || `HTTP ${foldersRes.status}`);
2168
- const folders = Array.isArray(foldersJson) ? foldersJson : foldersJson.folders || [];
2169
- const folder = folders.find((f) => f.name === folderName);
2170
- if (!folder) return fail(`no folder named "${folderName}" in this org`);
2171
- // The server fetches + scans; the CLI only resolves the association.
2172
- const payload = { gitUrl: url, folderId: folder.id, slug: opts.slug, description: opts.description, branch: opts.branch };
2173
- const res = await authFetch(`${server}/api/repos`, {
2174
- method: 'POST',
2175
- headers: { 'Content-Type': 'application/json', ...orgHeaders },
2176
- body: JSON.stringify(payload),
2177
- });
2178
- const data = await res.json().catch(() => ({}));
2179
- if (!res.ok) return fail(data.error || `HTTP ${res.status}`);
2180
- const created = res.status === 201;
2181
- if (opts.format === 'json') {
2182
- console.log(JSON.stringify({ ok: true, command: 'repo:add', data: { ...data, created } }));
2183
- } else {
2184
- console.log([created ? 'added' : 'updated', data.slug || '', data.gitUrl || url, folderName, `branch:${data.branch || '-'}`, `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`].join('\t'));
2185
- }
2159
+ .action(async (opts) => {
2160
+ const data = await readApiGet('repo:list', '/api/repos', opts.org);
2161
+ const rows = data.repos || [];
2162
+ if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
2163
+ for (const r of rows) console.log([r.slug, r.folderName || '-', r.gitUrl, `branch:${r.branch || '-'}`, `skills:${r.skillCount ?? 0}`, `identities:${r.identityCount ?? 0}`, `wiki:${r.wikiCount ?? 0}`].join('\t'));
2186
2164
  });
2165
+
2187
2166
  repoCmd
2188
- .command('list')
2189
- .description('List the org\'s linked repos with their folder + .agents/ index counts')
2167
+ .command('entries')
2168
+ .description('Search the git index across the org\'s connected repos the .agents/ skills and identities and .wiki/ pages each one exposes')
2190
2169
  .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2170
+ .option('--query <q>', 'match against name, slug and description')
2171
+ .option('--kind <kind>', 'restrict to one of: skill, identity, wiki')
2191
2172
  .option('--format <fmt>', 'output format: json or text', 'text')
2192
2173
  .action(async (opts) => {
2193
- const data = await readApiGet('repo:list', '/api/repos', opts.org);
2194
- const rows = data.repos || [];
2174
+ const params = new URLSearchParams();
2175
+ if (opts.query) params.set('q', opts.query);
2176
+ if (opts.kind) params.set('kind', opts.kind);
2177
+ const qs = params.toString();
2178
+ const data = await readApiGet('repo:entries', `/api/repos/entries${qs ? '?' + qs : ''}`, opts.org);
2179
+ const rows = data.entries || [];
2195
2180
  if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
2196
- for (const r of rows) console.log([r.slug, r.folderName || '-', r.gitUrl, `branch:${r.branch || '-'}`, `skills:${r.skillCount ?? 0}`, `identities:${r.identityCount ?? 0}`].join('\t'));
2181
+ if (!rows.length) { console.log('no entries'); return; }
2182
+ for (const e of rows) console.log([e.kind, e.slug || e.name, `repo:${e.repoSlug || '-'}`, e.sourcePath || ''].join('\t'));
2197
2183
  });
2198
2184
 
2199
2185
  repoCmd
2200
2186
  .command('rescan <slugOrId>')
2201
- .description('Re-fetch the tracked branch and refresh a repo\'s .agents/ index (idempotent; reports staleness via lastScannedAt/Sha)')
2187
+ .description('Re-fetch the tracked branch and refresh a repo\'s git index — .agents/ and .wiki/ (idempotent; reports staleness via lastScannedAt/Sha)')
2202
2188
  .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2203
2189
  .option('--format <fmt>', 'output format: json or text', 'text')
2204
2190
  .action(async (slugOrId, opts) => {
@@ -2220,34 +2206,7 @@ repoCmd
2220
2206
  const data = await res.json().catch(() => ({}));
2221
2207
  if (!res.ok) { const m = data.error || `HTTP ${res.status}`; if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:rescan', error: m })); else console.log(['error', m].join('\t')); process.exit(1); }
2222
2208
  if (opts.format === 'json') console.log(JSON.stringify({ ok: true, command: 'repo:rescan', data }));
2223
- else console.log(['rescaned', data.slug || '', data.branch || '-', `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`].join('\t'));
2224
- });
2225
-
2226
- repoCmd
2227
- .command('remove <slugOrId>')
2228
- .description('Remove a registered repo (cascades its index entries)')
2229
- .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
2230
- .option('--format <fmt>', 'output format: json or text', 'text')
2231
- .action(async (slugOrId, opts) => {
2232
- requireLogin();
2233
- const orgHeaders = opts.org ? { 'X-Drafted-Org': opts.org } : {};
2234
- const server = getServerUrl().replace(/\/$/, '');
2235
- // UUID? DELETE directly. Otherwise resolve slug -> id via the list.
2236
- const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
2237
- let id = isUuid ? slugOrId : null;
2238
- if (!id) {
2239
- const lookup = await authFetch(`${server}/api/repos`, { headers: orgHeaders });
2240
- const lj = await lookup.json().catch(() => ({}));
2241
- if (!lookup.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: lj.error || `HTTP ${lookup.status}` })); else console.log(['error', lj.error || `HTTP ${lookup.status}`].join('\t')); process.exit(1); }
2242
- const found = (lj.repos || []).find((r) => r.slug === slugOrId);
2243
- if (!found) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: `no repo with slug "${slugOrId}"` })); else console.log(['error', `no repo with slug "${slugOrId}"`].join('\t')); process.exit(1); }
2244
- id = found.id;
2245
- }
2246
- const res = await authFetch(`${server}/api/repos/${encodeURIComponent(id)}`, { method: 'DELETE', headers: orgHeaders });
2247
- const data = await res.json().catch(() => ({}));
2248
- if (!res.ok) { if (opts.format === 'json') console.log(JSON.stringify({ ok: false, command: 'repo:remove', error: data.error || `HTTP ${res.status}` })); else console.log(['error', data.error || `HTTP ${res.status}`].join('\t')); process.exit(1); }
2249
- if (opts.format === 'json') console.log(JSON.stringify({ ok: true, command: 'repo:remove', data: { id } }));
2250
- else console.log(['removed', id].join('\t'));
2209
+ else console.log(['rescaned', data.slug || '', data.branch || '-', `skills:${data.skills ?? 0}`, `identities:${data.identities ?? 0}`, `wiki:${data.wiki ?? 0}`].join('\t'));
2251
2210
  });
2252
2211
 
2253
2212
  program.parse();
package/cli/repo-scan.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // (slug + name). See docs/plans/org-substrate-normalization.md.
9
9
 
10
10
  import { readFileSync, readdirSync, existsSync, mkdtempSync } from 'fs';
11
- import { join } from 'path';
11
+ import { join, relative, sep } from 'path';
12
12
  import { tmpdir } from 'os';
13
13
  import { execSync } from 'child_process';
14
14
  import { parse as parseYaml } from 'yaml';
@@ -30,10 +30,12 @@ export function parseFrontmatter(text) {
30
30
  // ── Scan ────────────────────────────────────────────────────────────
31
31
 
32
32
  /**
33
- * Scan <dir>/.agents/skills/<name>/SKILL.md and <dir>/.agents/identities/<slug>/
34
- * identity.yaml. Returns { skills, identities } catalog entries (metadata +
35
- * pointers only — no file content). A skill with no frontmatter still indexes
36
- * (name falls back to the dir). Identities index {slug, name} from identity.yaml.
33
+ * Scan <dir>/.agents/skills/<name>/SKILL.md, <dir>/.agents/identities/<slug>/
34
+ * identity.yaml and <dir>/.wiki/**.md. Returns { skills, identities, wiki }
35
+ * catalog entries (metadata + pointers only — no file content). A skill with no
36
+ * frontmatter still indexes (name falls back to the dir). Identities index
37
+ * {slug, name} from identity.yaml. A wiki page's slug is its `.wiki/`-relative
38
+ * path with `.md` stripped, which is exactly its Drafted wiki path.
37
39
  */
38
40
  export function scanRepoAgents(dir) {
39
41
  const skills = [];
@@ -84,7 +86,54 @@ export function scanRepoAgents(dir) {
84
86
  }
85
87
  }
86
88
 
87
- return { skills, identities };
89
+ return { skills, identities, wiki: scanRepoWiki(dir) };
90
+ }
91
+
92
+ /**
93
+ * Scan <dir>/.wiki/**.md. One entry per page; `index.md` is skipped at every
94
+ * level because Drafted SYNTHESIZES it on read (a reserved path, never a stored
95
+ * page) — indexing it would shadow the synthesis with a stale copy.
96
+ */
97
+ export function scanRepoWiki(dir) {
98
+ const root = join(dir, '.wiki');
99
+ if (!existsSync(root)) return [];
100
+ const pages = [];
101
+ for (const file of walkMarkdown(root)) {
102
+ const rel = relative(root, file).split(sep).join('/');
103
+ if (rel.split('/').pop() === 'index.md') continue;
104
+ const text = readFileSync(file, 'utf8');
105
+ const fm = parseFrontmatter(text) || {};
106
+ const slug = rel.replace(/\.md$/, '');
107
+ pages.push({
108
+ kind: 'wiki',
109
+ name: String(fm.title || firstHeading(text) || slug.split('/').pop()),
110
+ slug,
111
+ description: fm.description != null ? String(fm.description) : '',
112
+ sourcePath: `.wiki/${rel}`,
113
+ });
114
+ }
115
+ return pages.sort((a, b) => a.slug.localeCompare(b.slug));
116
+ }
117
+
118
+ function firstHeading(text) {
119
+ const m = text.match(/^#\s+(.+)$/m);
120
+ return m ? m[1].trim() : null;
121
+ }
122
+
123
+ /** Recursively yield every `.md` file under dir, skipping dot-entries. */
124
+ function* walkMarkdown(dir) {
125
+ let entries;
126
+ try {
127
+ entries = readdirSync(dir, { withFileTypes: true });
128
+ } catch {
129
+ return;
130
+ }
131
+ for (const e of entries) {
132
+ if (e.name.startsWith('.')) continue;
133
+ const full = join(dir, e.name);
134
+ if (e.isDirectory()) yield* walkMarkdown(full);
135
+ else if (e.isFile() && e.name.endsWith('.md')) yield full;
136
+ }
88
137
  }
89
138
 
90
139
  function readdirSafe(dir) {
@@ -95,6 +144,41 @@ function readdirSafe(dir) {
95
144
  }
96
145
  }
97
146
 
147
+ // ── Seed-on-link ────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Classify what seeding a folder's wiki + skills onto a branch would do.
151
+ *
152
+ * Linking is a MIGRATION, not a flag flip: the folder's existing content must
153
+ * land in git BEFORE the folder flips to repo-owned, or every page and skill it
154
+ * already holds becomes unauthorable and shadowed. So a path already on the
155
+ * branch is a CONFLICT — refuse the link and list them — unless the content is
156
+ * identical, which makes it a no-op and keeps `link -> detach -> link` idempotent.
157
+ *
158
+ * @param {Map<string,string>|object} existing branch path -> file content
159
+ * @param {{path:string, content:string}[]} seed what the folder would write
160
+ * @returns {{creates:string[], identical:string[], conflicts:string[]}}
161
+ */
162
+ export function classifySeed(existing, seed) {
163
+ const get = existing instanceof Map ? (p) => existing.get(p) : (p) => existing?.[p];
164
+ const out = { creates: [], identical: [], conflicts: [] };
165
+ for (const f of seed || []) {
166
+ const there = get(f.path);
167
+ if (there === undefined || there === null) out.creates.push(f.path);
168
+ else if (sameContent(there, f.content)) out.identical.push(f.path);
169
+ else out.conflicts.push(f.path);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ // ponytail: byte equality modulo line endings and trailing blank space. Enough
175
+ // to make a re-link a no-op; if a semantic (frontmatter-order) diff ever matters,
176
+ // compare parsed OKF instead.
177
+ function sameContent(a, b) {
178
+ const norm = (s) => String(s ?? '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
179
+ return norm(a) === norm(b);
180
+ }
181
+
98
182
  /** Best-effort default branch of a git checkout. 'main' if git is unavailable. */
99
183
  export function detectDefaultBranch(dir) {
100
184
  try {
package/mcp/server.mjs CHANGED
@@ -19,6 +19,7 @@ import { z } from 'zod';
19
19
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
20
20
  import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
+ import { liftFolderScope, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
22
23
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
24
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
24
25
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
@@ -273,6 +274,8 @@ export function splitOrgScope(raw) {
273
274
  return { path: m[2] ? `/${m[2]}` : '/', org: decodeURIComponent(m[1]) };
274
275
  }
275
276
 
277
+ const ROOT_HINT = '<base>/wiki, <base>/skills, <base>/tasks, <base>/projects';
278
+
276
279
  // Accept a FULL share URL (https://drafted.live/o/...?...) — humans paste links,
277
280
  // not paths. The pathname of a Drafted URL IS the fs path (Q2: one canonical link
278
281
  // for both consumers), so stripping the origin leaves an addressable path. Falls
@@ -1072,6 +1075,13 @@ function workingOrgId() {
1072
1075
  return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
1073
1076
  }
1074
1077
 
1078
+ // One `ls` level of folders, from the org's folder list. Defined inside the
1079
+ // closure because `api` is — it carries this session's auth and org scope.
1080
+ async function listSubfolders(base, prefix, orgHeader) {
1081
+ const rows = await api('GET', '/api/folders', undefined, orgHeader).catch(() => []);
1082
+ return subfolderEntries(base, prefix, (Array.isArray(rows) ? rows : []).map((r) => r?.name));
1083
+ }
1084
+
1075
1085
  async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
1076
1086
  await ensureSession();
1077
1087
  const pid = getState().projectId;
@@ -2966,12 +2976,12 @@ server.resource('info', 'drafted://info', {
2966
2976
  text: JSON.stringify({
2967
2977
  version: PACKAGE_VERSION,
2968
2978
  layers: LAYERS,
2969
- pathFormat: '/o/<org>/<root>/<path> — org first, no org switching',
2979
+ pathFormat: '/o/<org>/<folder…?>/<root>/<path> — org first, no org switching; a folder chain may precede any root',
2970
2980
  roots: {
2971
- wiki: '/o/<org>/wiki/<path>',
2972
- skills: '/o/<org>/skills/<slug>',
2973
- tasks: '/o/<org>/tasks/<lane?>/<file>',
2974
- projects: '/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>',
2981
+ wiki: '/o/<org>/<folder…?>/wiki/<path>',
2982
+ skills: '/o/<org>/<folder…?>/skills/<slug>',
2983
+ tasks: '/o/<org>/<folder…?>/tasks/<lane?>/<file>',
2984
+ projects: '/o/<org>/<folder…?>/projects/<project>/<layer>/<lane>/<file>',
2975
2985
  },
2976
2986
  tools: ['fs (ls/read/write/edit/mv/rm/mkdir/search)', 'whoami', 'auth', 'session', 'trigger', 'focus', 'tour', 'screenshot', 'minion'],
2977
2987
  }, null, 2),
@@ -2979,9 +2989,9 @@ server.resource('info', 'drafted://info', {
2979
2989
  };
2980
2990
  });
2981
2991
 
2982
- tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2992
+ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: open, in_progress, scheduled, needs_review, needs_decision, done, failed (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call), `link` / `unlink` / `links` (relate one frame to another frame, to a project, or to an external url — `links` lists a frame\'s edges plus its backlinks, and on a project path lists the tasks linked to that project; a link is stored by ID, so `mv` never breaks it). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2983
2993
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
2984
- path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/tasks/... | /o/<org>/projects/... (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
2994
+ path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
2985
2995
  to: z.string().optional().describe('[mv] destination path; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
2986
2996
  url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
2987
2997
  query: z.string().optional().describe('[search] term to match against names/content'),
@@ -3025,7 +3035,24 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3025
3035
  }
3026
3036
  to = toScoped.path;
3027
3037
  }
3028
- const orgHeader = org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {});
3038
+ // Folder chain (Wave 0): folder is the single recursive container and the org
3039
+ // is that node at depth 0, so EVERY level carries the same four roots. The
3040
+ // root keyword is the separator — everything before wiki/skills/tasks/projects
3041
+ // is the folder chain, everything after is the path within that root. The
3042
+ // folder rides the REQUEST (X-Drafted-Folder); it is never stored, because a
3043
+ // "current folder" would be the retired org cursor rebuilt one level down.
3044
+ const pScope = liftFolderScope(p, to);
3045
+ if (pScope.error) return err(new Error(`${action} ${pScope.error}`));
3046
+ const folder = pScope.folder;
3047
+ to = pScope.to;
3048
+ const orgHeader = {
3049
+ ...(org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {})),
3050
+ ...(folder ? { 'X-Drafted-Folder': folder } : {}),
3051
+ };
3052
+ // The folder chain has been lifted into the header — everything below
3053
+ // dispatches on the ROOT, at any depth, with no per-root folder handling.
3054
+ const folderBase = `${orgFromPath ? `/o/${orgFromPath}` : ''}${folder ? '/' + folder : ''}`;
3055
+ if (pScope.rest) p = pScope.rest;
3029
3056
  const gs = getSessionState().gates;
3030
3057
 
3031
3058
  // ── Root listing: / or empty → the orgs this session can address (Shape A) ──
@@ -3060,7 +3087,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3060
3087
 
3061
3088
  section('Wiki', wiki, (v) => {
3062
3089
  const hits = (v?.hits || v?.pages || v?.results || []).slice(0, 10);
3063
- return hits.length ? hits.map(h => ` ${scope}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
3090
+ return hits.length ? hits.map(h => ` ${scope}${h.folder ? `/${h.folder}` : ''}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
3064
3091
  });
3065
3092
  section('Skills', skills, (v) => {
3066
3093
  // Fewer than the other legs on purpose: skills search is fuzzy and its tail
@@ -3091,13 +3118,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3091
3118
  }
3092
3119
  if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, /o/<org>/tasks, or /o/<org>/projects'));
3093
3120
  if (orgFromPath) {
3094
- // ls /o/<org> → that org's roots
3095
- return ok([
3096
- { name: 'wiki', type: 'directory', path: `/o/${orgFromPath}/wiki`, hint: 'org knowledge pages (markdown, OKF)' },
3097
- { name: 'skills', type: 'directory', path: `/o/${orgFromPath}/skills`, hint: 'reusable procedures (flat: one dir per skill slug)' },
3098
- { name: 'tasks', type: 'directory', path: `/o/${orgFromPath}/tasks`, hint: 'work items — a task is a frame; status/assignee are front matter on read' },
3099
- { name: 'projects', type: 'directory', path: `/o/${orgFromPath}/projects`, hint: '<folder?>/<project>/<layer>/<lane>/<file>' },
3100
- ]);
3121
+ // ls /o/<org> → the org IS the depth-0 folder: its four roots, plus the
3122
+ // folders nested inside it (each carrying the same four).
3123
+ return ok([...rootEntries(`/o/${orgFromPath}`), ...await listSubfolders(`/o/${orgFromPath}`, '', orgHeader)]);
3101
3124
  }
3102
3125
  // ls / → the orgs (id, slug, name) the agent belongs to; org is the top folder
3103
3126
  const orgs = await getOrgList();
@@ -3105,10 +3128,33 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3105
3128
  return ok(orgs.map(o => ({ name: o.name || o.id, type: 'directory', path: `/o/${o.slug || o.id}`, hint: 'org — fs(ls, path="/o/<org>") shows its wiki/skills/projects' })));
3106
3129
  }
3107
3130
 
3131
+ // ── A path with no root keyword names a FOLDER, at any depth ──────
3132
+ // /o/<org>/engineering, /o/<org>/engineering/backend, … Same shape as the org
3133
+ // itself: four roots plus whatever folders nest inside.
3134
+ if (!pScope.rest && folder) {
3135
+ if (action === 'ls') {
3136
+ return ok([...rootEntries(folderBase), ...await listSubfolders(folderBase, folder, orgHeader)]);
3137
+ }
3138
+ if (action === 'mkdir') {
3139
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3140
+ const created = await api('POST', '/api/folders', { name: folder }, orgHeader);
3141
+ return ok({ created: true, folder: created?.name || folder, id: created?.id, path: folderBase,
3142
+ hint: 'a folder carries its own wiki, skills, tasks and projects' });
3143
+ }
3144
+ if (action === 'rm') {
3145
+ await requireBoundOrgForProjectlessMutation(org || orgFromPath);
3146
+ const rows = await api('GET', '/api/folders', undefined, orgHeader);
3147
+ const row = (Array.isArray(rows) ? rows : []).find((f) => f.name === folder);
3148
+ if (!row) return err(new Error(`no such folder: ${folder}`));
3149
+ return ok(await api('DELETE', `/api/folders/${row.id}`, undefined, orgHeader));
3150
+ }
3151
+ return err(new Error(`${folderBase} is a folder — address one of its roots: ${ROOT_HINT.replace('<base>', folderBase)}`));
3152
+ }
3153
+
3108
3154
  // ── mkdir: create a project (a directory in the /projects root) ──
3109
3155
  if (action === 'mkdir') {
3110
3156
  if (!p.startsWith('/projects')) {
3111
- return err(new Error('mkdir is only meaningful under /projects (wiki dirs are implicit; skills are flat) e.g. fs(mkdir, path="/o/<org>/projects/my-project")'));
3157
+ return err(new Error('mkdir creates a project (/o/<org>/projects/<name>) or a folder (/o/<org>/<folder>, which then carries its own four roots) — wiki dirs are implicit and skills are flat, so neither needs one.'));
3112
3158
  }
3113
3159
  // A project create is project-less: it must not guess the org.
3114
3160
  await requireBoundOrgForProjectlessMutation(org || orgFromPath);
@@ -3116,18 +3162,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3116
3162
  if (parts.length === 0 || parts.length > 2) {
3117
3163
  return err(new Error(`mkdir path must be /projects/<name> or /projects/<folder>/<name> — got ${p}`));
3118
3164
  }
3119
- const folder = parts.length === 2 ? parts[0] : null;
3165
+ // Two spellings reach the same place: the folder chain before the root
3166
+ // (/o/<org>/eng/projects/x) or the legacy segment after it
3167
+ // (/o/<org>/projects/eng/x). The chain wins when both are given.
3168
+ const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
3120
3169
  const projectRef = parts[parts.length - 1];
3121
3170
  const existing = await resolveProjectRef(projectRef).catch(() => null);
3122
3171
  if (existing) return err(new Error(`project already exists: ${projectRef}`));
3123
3172
  const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
3124
3173
  const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
3125
3174
  if (!created?.id) return err(new Error('project create returned no id'));
3126
- if (folder) {
3127
- try { await api('PATCH', `/api/project/${created.id}`, { folder }, orgHeader); } catch { /* folder best-effort */ }
3175
+ if (projectFolder) {
3176
+ try { await api('PATCH', `/api/project/${created.id}`, { folder: projectFolder }, orgHeader); } catch { /* folder best-effort */ }
3128
3177
  }
3129
3178
  projectRefCache.set(projectRef, { id: created.id, slug: created.slug, name: created.name, orgId: created.orgId, orgSlug: created.orgSlug });
3130
- return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${orgFromPath ? `/o/${orgFromPath}` : ''}/projects${folder ? '/' + folder : ''}/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
3179
+ return ok({ created: true, name: created.name, slug: created.slug, id: created.id, path: `${folderBase}/projects/${created.slug}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${created.slug}` });
3131
3180
  }
3132
3181
 
3133
3182
  // ── Root: /wiki/... ────────────────────────────────────────────
@@ -3753,12 +3802,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3753
3802
  // Agents reach the repos table through this tool. Content stays in git;
3754
3803
  // Drafted keeps a read-only index. `add`/`rescan` mutate; `list`/`entries`
3755
3804
  // are read-only and paginated with a compact mode (collection rule).
3756
- tool('repo', 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills (git owns content, Drafted renders + indexes). Dispatch by `action`:\n- `list` — the org\'s linked repos (paginated, compact mode).\n- `add` link a GitHub repo to a folder; the server fetches + indexes .agents/. `branch` points at a named branch (fails loudly if missing).\n- `rescan` — re-fetch the tracked branch and refresh the index.\n- `entries` — search the index across all the org\'s repos (skills + identities).\n\nAuthoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo — commit to git instead.', {
3757
- action: z.enum(['list', 'add', 'rescan', 'entries']).describe('list: org repos; add: link a repo; rescan: re-fetch the tracked branch; entries: search the index.'),
3758
- gitUrl: z.string().optional().describe('[add] GitHub repo URL to link (e.g. https://github.com/owner/repo). Required for add.'),
3759
- folder: z.string().optional().describe('[add] folder name to link the repo into (required for add; repos are folder-scoped).'),
3760
- branch: z.string().optional().describe('[add] git branch to track (default: the repo default; fails loudly if the branch is missing).'),
3761
- slug: z.string().optional().describe('[add] repo slug (defaults to the repo name from the URL); [rescan] the repo slug or UUID.'),
3805
+ tool('repo', 'Registered git repos — the org index of .agents/ skills + identities. A connected repo is the source of truth for its skills (git owns content, Drafted renders + indexes). Dispatch by `action`:\n- `list` — the org\'s linked repos (paginated, compact mode).\n\nLINKING AND UNLINKING ARE NOT HERE. A person does them in the Drafted UI (Settings > Organization > GitHub to connect an account, then the folder menu), because linking moves a folder\'s wiki + skills into git and makes Drafted read-only for them. Agents read and rescan; they do not change who owns the content.\n- `rescan` — re-fetch the tracked branch and refresh the index.\n- `entries` — search the index across all the org\'s repos (skills + identities).\n\nAuthoring a Drafted skill whose slug collides with a repo-indexed skill returns 409 repo_owned pointing at the repo — commit to git instead.', {
3806
+ action: z.enum(['list', 'entries', 'rescan']).describe('list: the org\'s connected repos; entries: search the git index across them; rescan: re-fetch a tracked branch and refresh its index.'),
3807
+ slug: z.string().optional().describe('[rescan] the repo slug or UUID.'),
3762
3808
  repoId: z.string().optional().describe('[rescan] repo UUID (alternative to slug).'),
3763
3809
  query: z.string().optional().describe('[entries] search term (matches name/description/slug).'),
3764
3810
  kind: z.enum(['skill', 'identity']).optional().describe('[entries] filter to skill or identity.'),
@@ -3767,7 +3813,7 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
3767
3813
  compact: z.boolean().optional().describe('list/entries: return only identity fields {slug,name,gitUrl} / {slug,name,kind} (default false).'),
3768
3814
  org: z.string().optional().describe('Org (id or name) to scope this call; defaults to the session\'s working org.'),
3769
3815
  }, async (args) => {
3770
- const { action, gitUrl, folder, branch, slug, repoId, query, kind, limit, offset, compact, org } = args;
3816
+ const { action, slug, repoId, query, kind, limit, offset, compact, org } = args;
3771
3817
  const orgHeader = org ? { 'X-Drafted-Org': org } : {};
3772
3818
  const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
3773
3819
  const start = Math.max(0, Math.floor(Number(offset) || 0));
@@ -3783,17 +3829,6 @@ tool('repo', 'Registered git repos — the org index of .agents/ skills + identi
3783
3829
  }))),
3784
3830
  });
3785
3831
  }
3786
- if (action === 'add') {
3787
- if (!gitUrl) return err(new Error('add requires gitUrl (e.g. https://github.com/owner/repo)'));
3788
- if (!folder) return err(new Error('add requires folder (the folder name to link the repo into — repos are folder-scoped)'));
3789
- // Resolve folder name -> id in the target org.
3790
- const foldersRes = await api('GET', '/api/folders', undefined, orgHeader);
3791
- const folders = Array.isArray(foldersRes) ? foldersRes : (foldersRes?.folders || []);
3792
- const f = folders.find((x) => x.name === folder);
3793
- if (!f) return err(new Error(`no folder named "${folder}" in this org`));
3794
- const result = await api('POST', '/api/repos', { gitUrl, folderId: f.id, branch: branch || undefined, slug: slug || undefined }, orgHeader);
3795
- return ok({ slug: result.slug, gitUrl: result.gitUrl, branch: result.branch, skills: result.skills, identities: result.identities });
3796
- }
3797
3832
  if (action === 'rescan') {
3798
3833
  const id = repoId || await (async () => {
3799
3834
  if (!slug) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.19.16",
3
+ "version": "1.19.17",
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,160 @@
1
+ /**
2
+ * Folder is the single recursive container: the org is that node at depth 0, and
3
+ * EVERY folder level carries the same four roots (wiki, skills, tasks, projects).
4
+ *
5
+ * /o/<org>/ folder (depth 0)
6
+ * wiki/ skills/ tasks/ projects/
7
+ * engineering/ folder
8
+ * wiki/ skills/ tasks/ projects/
9
+ *
10
+ * The grammar is unambiguous by construction: the ROOT KEYWORD IS THE SEPARATOR.
11
+ * Everything before the first `wiki`/`skills`/`tasks`/`projects` segment is the
12
+ * folder chain; everything after is the path within that root. So
13
+ * `/wiki/engineering/foo` is the org wiki's nested page and
14
+ * `/engineering/wiki/foo` is the `engineering` folder's — distinct strings, no
15
+ * collision. The one constraint this imposes: the four names are RESERVED and a
16
+ * folder may not take one.
17
+ *
18
+ * A folder is ADDRESSED, never remembered: the chain travels in the path (and
19
+ * from there in a request-local `X-Drafted-Folder` header), so no shared row
20
+ * carries a "current folder". A cursor here would be the retired
21
+ * `sessions.org_id` mistake rebuilt one level down — see the Drafted wiki page
22
+ * `engineering/org-addressing-model`.
23
+ */
24
+
25
+ export const ROOTS = ['wiki', 'skills', 'tasks', 'projects'];
26
+ const RESERVED = new Set(ROOTS);
27
+
28
+ export function isReservedFolderName(name) {
29
+ return RESERVED.has(String(name || '').trim().toLowerCase());
30
+ }
31
+
32
+ /** '' (org root) or 'a/b' — no leading/trailing slashes, no empty segments. */
33
+ export function normalizeFolder(raw) {
34
+ return String(raw || '')
35
+ .split('/')
36
+ .map((s) => s.trim())
37
+ .filter(Boolean)
38
+ .join('/');
39
+ }
40
+
41
+ /** null when the folder path is usable, else the reason it is not. */
42
+ export function folderPathError(raw) {
43
+ const segs = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
44
+ if (!segs.length) return 'Folder name is required';
45
+ for (const s of segs) {
46
+ if (isReservedFolderName(s)) {
47
+ return `"${s}" is a reserved name — wiki, skills, tasks and projects are the four roots every folder carries, so a folder cannot be called one`;
48
+ }
49
+ if (s.length > 120) return `Folder name too long: "${s.slice(0, 40)}…"`;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Split a path into its folder chain and the root-relative remainder.
56
+ *
57
+ * /engineering/backend/wiki/foo → { folder: 'engineering/backend', rest: '/wiki/foo' }
58
+ * /wiki/engineering/foo → { folder: '', rest: '/wiki/engineering/foo' }
59
+ * /engineering → { folder: 'engineering', rest: '' }
60
+ * / → { folder: '', rest: '' }
61
+ *
62
+ * `rest` is '' when the path names a folder itself (no root keyword in it).
63
+ */
64
+ export function splitFolderScope(path) {
65
+ const segs = String(path || '').split('/').filter(Boolean);
66
+ const at = segs.findIndex((s) => RESERVED.has(s));
67
+ if (at === -1) return { folder: segs.join('/'), rest: '' };
68
+ return { folder: segs.slice(0, at).join('/'), rest: '/' + segs.slice(at).join('/') };
69
+ }
70
+
71
+ /** Re-join a folder chain and a root-relative path into one addressable path. */
72
+ export function joinFolderScope(folder, rest) {
73
+ const f = normalizeFolder(folder);
74
+ const r = String(rest || '').replace(/^\/+/, '');
75
+ return '/' + [f, r].filter(Boolean).join('/');
76
+ }
77
+
78
+ /** The four roots as ls entries, for a folder at any depth (base = '/o/<org>[/<folder>]'). */
79
+ export function rootEntries(base) {
80
+ const b = String(base || '').replace(/\/+$/, '');
81
+ const hints = {
82
+ wiki: 'knowledge pages (markdown, OKF)',
83
+ skills: 'reusable procedures (flat: one dir per skill slug)',
84
+ tasks: 'work items — a task is a frame; status/assignee are front matter on read',
85
+ projects: '<project>/<layer>/<lane>/<file>',
86
+ };
87
+ return ROOTS.map((name) => ({ name, type: 'directory', path: `${b}/${name}`, hint: hints[name] }));
88
+ }
89
+
90
+ /**
91
+ * The folder lift for an fs call: pull the folder chain off the source path (and
92
+ * off the destination, for mv), leaving root-relative paths the per-root
93
+ * dispatch can use unchanged at any depth.
94
+ *
95
+ * A move stays within one folder — silently re-homing a page across folders on
96
+ * a rename is the kind of quiet reorganisation that loses work.
97
+ */
98
+ export function liftFolderScope(path, to) {
99
+ const src = splitFolderScope(path);
100
+ const folder = src.folder;
101
+ if (folder) {
102
+ const bad = folderPathError(folder);
103
+ if (bad) return { error: bad };
104
+ }
105
+ const out = { folder, path: src.rest || path, rest: src.rest, to };
106
+ if (to) {
107
+ const dst = splitFolderScope(to);
108
+ if (dst.rest) {
109
+ if (normalizeFolder(dst.folder) !== normalizeFolder(folder)) {
110
+ return { error: `stays within one folder — source is /${folder || ''} and destination is /${dst.folder || ''}` };
111
+ }
112
+ out.to = dst.rest;
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * The folders nested DIRECTLY inside `prefix` ('' = the org itself), given the
120
+ * org's folder names (each of which is its full path). One segment deeper only —
121
+ * `ls` shows a level, not a tree.
122
+ */
123
+ export function subfolderEntries(base, prefix, names) {
124
+ const depth = prefix ? normalizeFolder(prefix).split('/').length : 0;
125
+ const kids = new Set();
126
+ for (const raw of names || []) {
127
+ const name = normalizeFolder(raw);
128
+ if (!name) continue;
129
+ if (prefix && !name.startsWith(normalizeFolder(prefix) + '/')) continue;
130
+ const segs = name.split('/');
131
+ if (segs.length > depth) kids.add(segs[depth]);
132
+ }
133
+ return [...kids].sort().map((name) => ({
134
+ name,
135
+ type: 'directory',
136
+ path: `${String(base || '').replace(/\/+$/, '')}/${name}`,
137
+ hint: 'folder — carries its own wiki, skills, tasks and projects',
138
+ }));
139
+ }
140
+
141
+ /**
142
+ * Process surfaces: wiki pages agents WRITE through the tool rather than read.
143
+ * Promote-to-commons appends to the wiki `log`, and a promotion that
144
+ * contradicts canon is flagged in `review`.
145
+ *
146
+ * They must be excluded from the seed AND exempted from the repo_owned guard,
147
+ * and those two must agree. Excluded but not exempted, they end up unauthorable
148
+ * AND unwritten — the tool 409s a page git never received, and promote-to-commons
149
+ * breaks with no obvious cause. Lives here because the CLI seeder and the server
150
+ * guard both need it and drift between them is exactly the failure.
151
+ *
152
+ * Whole path segments only — `reference/log-shipping` is a normal page.
153
+ */
154
+ export const PROCESS_SURFACES = new Set(['log', 'review']);
155
+
156
+ export function isProcessSurface(path) {
157
+ return String(path || '')
158
+ .split('/')
159
+ .some((seg) => PROCESS_SURFACES.has(seg));
160
+ }