drafted 1.22.10 → 1.23.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/cli/drafted.mjs CHANGED
@@ -1962,7 +1962,7 @@ skillCmd
1962
1962
  let id = opts.id;
1963
1963
  let slug = opts.slug;
1964
1964
  if (!id && opts.slug) {
1965
- const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
1965
+ const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}?lookup=1`, { headers: orgHeaders });
1966
1966
  if (!lookup.ok) { emitSkillResult(opts.format, { status: 'unresolvable', error: `slug ${opts.slug}: HTTP ${lookup.status}` }); process.exit(1); }
1967
1967
  const lj = await lookup.json().catch(() => ({}));
1968
1968
  id = lj.id; slug = lj.slug;
@@ -1988,7 +1988,7 @@ skillCmd
1988
1988
  } else if (forkRes.status === 409) {
1989
1989
  // Org already owns a copy of this slug — update that one (still "forked").
1990
1990
  const ownSlug = data.slug || slug;
1991
- const own = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(ownSlug)}`, { headers: orgHeaders });
1991
+ const own = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(ownSlug)}?lookup=1`, { headers: orgHeaders });
1992
1992
  if (!own.ok) { emitSkillResult(opts.format, { status: 'error', error: `resolve existing fork ${ownSlug}: HTTP ${own.status}` }); process.exit(1); }
1993
1993
  forkId = (await own.json().catch(() => ({}))).id;
1994
1994
  } else {
@@ -2016,7 +2016,7 @@ async function checkOneSkill(ref, org) {
2016
2016
  const loadUrl = UUID_RE.test(idOrSlug)
2017
2017
  ? `${server}/api/skills/${idOrSlug}`
2018
2018
  : `${server}/api/skills/slug/${encodeURIComponent(idOrSlug)}`;
2019
- const res = await authFetch(loadUrl, { headers: orgHeaders });
2019
+ const res = await authFetch(`${loadUrl}?lookup=1`, { headers: orgHeaders });
2020
2020
  if (res.status === 404 || res.status === 403) return { ref, slug: '', hash: '', status: 'unresolvable', error: await unresolvableReason(res, org) };
2021
2021
  if (!res.ok) return { ref, slug: '', hash: '', status: 'error', error: `HTTP ${res.status}` };
2022
2022
  const skill = await res.json();
@@ -2092,7 +2092,7 @@ skillCmd
2092
2092
  async function resolveSkillId(opts, server, orgHeaders) {
2093
2093
  if (opts.id) return opts.id;
2094
2094
  if (!opts.slug) return null;
2095
- const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}`, { headers: orgHeaders });
2095
+ const lookup = await authFetch(`${server}/api/skills/slug/${encodeURIComponent(opts.slug)}?lookup=1`, { headers: orgHeaders });
2096
2096
  if (!lookup.ok) return null;
2097
2097
  return (await lookup.json().catch(() => ({}))).id || null;
2098
2098
  }
package/mcp/server.mjs CHANGED
@@ -440,7 +440,7 @@ BREADCRUMBS: When a frame you write or read corresponds to a file in the user's
440
440
 
441
441
  CONTEXT RULES (follow these before every action):
442
442
  - WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use fs(search, query="<terms>") with the /wiki root.
443
- - LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
443
+ - LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server does not enforce reading anchored frames (it does enforce anchored SKILLS: a write is refused with 428 anchor_gate until you have read them), so read them proactively to avoid wasted work.
444
444
  IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use fs(read, path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
445
445
 
446
446
  LINKING: link the user to the narrowest thing you touched, never the project as a stand-in. Wrote or edited ONE frame → give its \`frameUrl\` (from the write/read/ls response). Touched a whole lane → its \`laneUrl\`; a whole layer → its \`layerUrl\` (both on the matching \`ls\` entries). Only when the work spans the project is the project URL the right link. A project link where a frame link was available makes the user hunt the surface for what you just did.`,
@@ -2099,34 +2099,6 @@ async function orgEcho(result, explicitOrg) {
2099
2099
  } catch { return {}; }
2100
2100
  }
2101
2101
 
2102
- async function getOrgSkills(orgId) {
2103
- if (!orgId) return [];
2104
- // No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
2105
- // performed mid-session won't take effect until the cached [] expires.
2106
- try {
2107
- const result = await api('GET', `/api/orgs/${orgId}/skills`);
2108
- return Array.isArray(result?.skills) ? result.skills : [];
2109
- } catch { return []; }
2110
- }
2111
-
2112
- // Sum the per-project priming content (attached skill bodies + project anchor
2113
- // bodies) so deposits can be capped against the per-project context budget (G4/G5).
2114
- async function getProjectPrimingSize(projectId) {
2115
- if (!projectId) return 0;
2116
- let total = 0;
2117
- try {
2118
- const { skills = [] } = (await api('GET', `/api/projects/${projectId}/skills`)) || {};
2119
- for (const s of skills) {
2120
- try { const full = await api('GET', `/api/skills/${s.id}`); total += (full?.content || '').length; } catch { /* skip */ }
2121
- }
2122
- } catch { /* skills unavailable */ }
2123
- try {
2124
- const anchored = await api('GET', `/api/designs/anchored?projectId=${projectId}`);
2125
- for (const a of (Array.isArray(anchored) ? anchored : [])) total += (a?.content || '').length;
2126
- } catch { /* anchors unavailable */ }
2127
- return total;
2128
- }
2129
-
2130
2102
  // Best-effort size (chars) of a frame's content by path / URL / id — for the
2131
2103
  // anchor deposit cap. Returns 0 if it can't be read (cap then allows).
2132
2104
  async function getFrameContentSize(path) {
@@ -2145,19 +2117,6 @@ async function getFrameContentSize(path) {
2145
2117
  } catch { return 0; }
2146
2118
  }
2147
2119
 
2148
- async function checkOrgSkills(orgId, operation) {
2149
- if (!orgId) return null;
2150
- const skills = await getOrgSkills(orgId);
2151
- if (skills.length === 0) return null;
2152
- const loaded = getSessionState().loadedSkillIds;
2153
- const unloaded = skills.filter(s => !loaded.has(s.id));
2154
- if (unloaded.length === 0) return null;
2155
- const lines = unloaded.map(s => ` fs(read, path="/skills/${s.slug || s.id}") -- ${s.name}`);
2156
- return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
2157
- `Unloaded skills:\n${lines.join('\n')}\n\n` +
2158
- `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
2159
- }
2160
-
2161
2120
  // ── Wiki helpers ─────────────────────────────────────────────────
2162
2121
 
2163
2122
  function normalizeWikiPath(input) {
@@ -3371,7 +3330,7 @@ server.resource('info', 'drafted://info', {
3371
3330
  };
3372
3331
  });
3373
3332
 
3374
- 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. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\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 — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `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.', {
3333
+ 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. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\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 — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `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, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.', {
3375
3334
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3376
3335
  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)'),
3377
3336
  to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
@@ -3662,7 +3621,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3662
3621
  const existing = await resolveProjectRef(projectRef).catch(() => null);
3663
3622
  if (existing) return err(new Error(`project already exists: ${projectRef}`));
3664
3623
  const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
3665
- const created = await api('POST', '/api/projects', { name: projectName }, orgHeader);
3624
+ const created = await api('POST', '/api/projects', { name: projectName, ...(projectFolder ? { folder: projectFolder } : {}) }, orgHeader);
3666
3625
  if (!created?.id) return err(new Error('project create returned no id'));
3667
3626
  if (projectFolder) {
3668
3627
  try { await api('PATCH', `/api/project/${created.id}`, { folder: projectFolder }, orgHeader); } catch { /* folder best-effort */ }
@@ -3782,7 +3741,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3782
3741
  // A specific slug is a targeted ls: the skill's directory — SKILL.md
3783
3742
  // plus its supporting files, so a README is discoverable.
3784
3743
  if (slug) {
3785
- const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
3744
+ const s = await api('GET', `/api/skills/slug/${slug}?lookup=1`, undefined, orgHeader).catch(() => null);
3786
3745
  if (!s) return ok([]);
3787
3746
  const listed = await api('GET', `/api/skills/${s.id}/files`, undefined, orgHeader).catch(() => null);
3788
3747
  const files = (listed?.files || []).map((f) => f.path || f).filter((f) => f !== 'SKILL.md');
@@ -3795,7 +3754,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3795
3754
  case 'read': {
3796
3755
  if (!slug) return err(new Error('read /skills/<slug>'));
3797
3756
  if (!isSkillMd) {
3798
- const owner = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
3757
+ const owner = await api('GET', `/api/skills/slug/${slug}?lookup=1`, undefined, orgHeader).catch(() => null);
3799
3758
  if (!owner?.id) return err(new Error(`no Drafted-authored skill "${slug}" — supporting files exist only for skills stored in Drafted; a repo-indexed skill lives in git (read its SKILL.md with fs(read, path="/skills/${slug}"))`));
3800
3759
  const f = await api('GET', fileUrl(owner.id), undefined, orgHeader).catch(() => null);
3801
3760
  if (f?.content == null) return err(new Error(`file not found in skill ${slug}: ${filePath} (fs(ls, path="/skills/${slug}") lists what is there)`));
@@ -3818,7 +3777,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3818
3777
  try { text = textFromLocalFile(file_path); } catch (e) { return err(e); }
3819
3778
  }
3820
3779
  if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
3821
- const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
3780
+ const existing = await api('GET', `/api/skills/slug/${slug}?lookup=1`, undefined, orgHeader).catch(() => null);
3822
3781
  if (!isSkillMd) {
3823
3782
  // Supporting file inside an existing skill. It can't create the skill —
3824
3783
  // the skill row is what owns the file — so say what call does.
@@ -3858,7 +3817,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3858
3817
  return ok(result);
3859
3818
  }
3860
3819
  case 'rm': {
3861
- const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
3820
+ const s = await api('GET', `/api/skills/slug/${slug}?lookup=1`, undefined, orgHeader);
3862
3821
  if (!s?.id) return err(new Error(`skill not found: ${slug}`));
3863
3822
  if (!isSkillMd) return ok(await api('DELETE', fileUrl(s.id), undefined, orgHeader));
3864
3823
  return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.22.10",
3
+ "version": "1.23.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
  "bin": {
@@ -14,7 +14,7 @@ Search before creating (also satisfies the gates):
14
14
  Then:
15
15
  - Create the project — a project is a folder: `fs(mkdir, path="/o/<org>/projects/<name>")`. It starts with **no layers**; you define them by writing into them.
16
16
  - `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/brief.md", content=...)` a real brief at the earliest layer (goal, audience, constraints — 6-12 lines, not a placeholder). The layer + lane auto-create.
17
- - Attach the relevant skills you found via the skill gates.
17
+ - Attach the relevant skills you found; anchor any that agents must read before they write.
18
18
  - `fs(ls, path="/o/<org>/projects/<name>")` to confirm the structure, and hand the user the `projectUrl` from the response.
19
19
 
20
20
  For a **template**: build the layer structure + anchored guidance, then note how to fork it next time. Don't speculatively fill downstream frames — wait for direction.
@@ -7,10 +7,10 @@ When the user had to correct the work in this project, codify the correction so
7
7
 
8
8
  1. Identify the recurring correction(s) or standing rule from this session.
9
9
  2. Choose the right Drafted-side gate per correction (each counts toward the project's context budget):
10
- - **Project anchor** — a brief, constraint, or style guide that must be in context project-wide: `fs(write, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>", content=...)` it, then anchor it so it surfaces on open. [G5]
11
- - **Attached skill** — a procedure that must be loaded before work: `/drafted:create-skill` (or `fs(search, path="/o/<org>/skills", ...)` for an existing one), then attach it to the project. [G4]
12
- - **Layer rule** — a standing instruction for one stage: set that layer's rules so work in it is gated. [G6]
10
+ - **Project anchor** — a brief, constraint, or style guide that must be in context project-wide: `fs(write, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>", content=...)` it, then anchor it so it is listed as suggested reading (not enforced). [G5]
11
+ - **Attached skill** — a procedure that must be loaded before work: `/drafted:create-skill` (or `fs(search, path="/o/<org>/skills", ...)` for an existing one), then ask the user to anchor it to the project (Skills view > Anchoring, effect Enforce; an agent cannot do this itself, and attaching a skill alone does not gate). Writes in that scope are then refused until the skill is read. [G4]
12
+ - **Layer rule** — a standing instruction for one stage: set that layer's rules (an AGENTS.md in the layer) so they are surfaced when work happens there. [G6]
13
13
  3. Propose which mechanism for each correction, confirm with the user, then apply.
14
- 4. Confirm what is now enforced — the next session in this project will be gated on it automatically.
14
+ 4. Confirm what is now enforced — only an anchored skill gates writes; a project anchor and a layer rule do not.
15
15
 
16
16
  Keep within the project's context budget — if a deposit would exceed it, tighten existing gates instead of piling on.
@@ -7,7 +7,7 @@ Onboard the user to Drafted — a producibles harness that compounds across thre
7
7
 
8
8
  This is the over-arching prime command: orient, then seed all three stores so every later session starts smart.
9
9
 
10
- 1. **Orient** — in 3-4 lines explain the loop: you *prime* from the harness (the system makes you search the wiki, and auto-loads the project's attached skills, anchors, and layer rules before you work), you *build*, then you *compound* (deposit what you learned). The more it's used, the less searching and the more stable the work. Drafted is navigated like a filesystem — one `fs` tool, three roots: `/wiki`, `/skills`, `/projects`.
10
+ 1. **Orient** — in 3-4 lines explain the loop: you *prime* from the harness (the system makes you search the wiki, and refuses a write until you have read the skills anchored to where you are working), you *build*, then you *compound* (deposit what you learned). The more it's used, the less searching and the more stable the work. Drafted is navigated like a filesystem — one `fs` tool, three roots: `/wiki`, `/skills`, `/projects`.
11
11
  2. **Name the org** — the org is an address, not a cursor: address wiki/skills with `org=...` on the call, and projects self-derive their org from the path. Never rely on an "active" org: a project-less create by a multi-org user is refused rather than guessed.
12
12
  3. **Find the work (ask first).** Make your first question permission, offering three scopes, narrowest first: (a) "tell me what you're working on this week" (nothing is read), (b) one folder they point you to, (c) recent files plus their inbox, Drive and calendar. Option (c) needs `gws` signed in; check it as the `drafted` skill's *Source material from Google* section describes, and if it isn't, offer `! drafted google-setup`. Say plainly what stays on their machine (everything you read) and what reaches Drafted (only what they pick). Read headers before bodies. Then write one frame, **Your work, mapped**: the 3-7 strongest candidates for a Drafted project, each with why it fits (many artifacts, several people, a recurring process, a decision to make), its source links, and the project it would become. Let the user pick.
13
13
  4. **Seed knowledge** — run the `/drafted:ingest` flow: help the user point at existing business materials (folders, docs, past research, the sources behind the candidate they picked) or interrogate them for tacit knowledge, and land durable pages in the wiki.
@@ -31,7 +31,7 @@ Verbs: `ls` (list) · `read` (cat, hashline-annotated) · `write` (create/overwr
31
31
 
32
32
  ## The loop — prime → build → compound
33
33
 
34
- - **Prime (session start).** Pull accumulated value in. The system *enforces* this: you must search the wiki before working (G1), and a project's attached skills and anchored frames are required reading when you open it. Don't fight the gates — they make you start smart.
34
+ - **Prime (session start).** Pull accumulated value in. The system *enforces* this: you must search the wiki before working (G1), and the skills anchored to an org, folder or project must be read before you change content there (a refused write returns HTTP 428 `anchor_gate` with the rule text; read the skill, then retry). Don't fight the gates — they make you start smart.
35
35
  - **Build (the work).** Produce artifacts as frames on the surface.
36
36
  - **Compound (session end / when you notice something).** Deposit learning back: capture knowledge, distill or fix a skill, harden the project. This is *your* responsibility — the system can't force it, so do it.
37
37
 
@@ -42,8 +42,8 @@ These reset every session. A gate that blocks you tells you exactly what to call
42
42
  - **G1 — wiki search before work.** Before reading or editing anything, `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant org knowledge.
43
43
  - **G2 — prior-art before a new skill.** Writing a new `/o/<org>/skills/<slug>` requires `fs(search, path="/o/<org>/skills", query="<topic>")` first.
44
44
  - **G3 — prior-art before a new project.** Creating a project requires wiki + skill searches first.
45
- - **G4 — attached skills** are auto-injected when you open a project. Follow them — they're how the org does this work.
46
- - **G5 — the project's anchored frames** are required reading (briefs, constraints, style guides).
45
+ - **G4 — anchored skills** gate every content write (frames, wiki pages, skills) in the scope they are anchored to: an org, a folder or a project. The refusal (HTTP 428 `anchor_gate`) carries the rule text and the `fs(read)` path of each skill; read them, then retry. Editing a skill re-arms the gate. They are how the org does this work.
46
+ - **G5 — the project's anchored frames** are suggested reading (briefs, constraints, style guides). The server does not enforce them.
47
47
  - **G6 — a layer's rules** are surfaced when you work in that layer. Honor them.
48
48
 
49
49
  ## The commands (when to reach for each)
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Anchored skills: which scope does an fs-style path address?
3
+ *
4
+ * Pure, no I/O. The root keyword is the separator (see folder-path.mjs), so the
5
+ * folder chain and the gate surface both fall out of one split:
6
+ *
7
+ * /o/acme/eng/backend/projects/api/designs/default/x.html
8
+ * -> surface 'projects', folder 'eng/backend', project 'api'
9
+ * /wiki/eng/foo -> surface 'wiki', folder '' (the ORG wiki)
10
+ * /eng/wiki/foo -> surface 'wiki', folder 'eng' (the folder's wiki)
11
+ *
12
+ * Gate surfaces follow where the write lands: wiki -> wiki_pages, skills ->
13
+ * skills, projects AND tasks -> designs rows ('projects'; a task is a frame).
14
+ * A path that names no root (`/`, `/eng`) is a folder, not content: surface null.
15
+ *
16
+ * Id-addressed routes (layout, conflicts, designs/:id) cannot be classified
17
+ * from a path; they resolve id -> project -> projects.folder and then call
18
+ * buildScopeChain themselves.
19
+ */
20
+ import { ROOTS, folderPathError, normalizeFolder, splitFolderScope } from './folder-path.mjs';
21
+
22
+ const SURFACE_OF_ROOT = { wiki: 'wiki', skills: 'skills', tasks: 'projects', projects: 'projects' };
23
+
24
+ /**
25
+ * @returns {{ org: string|null, folder: string, root: string|null, surface: 'projects'|'wiki'|'skills'|null,
26
+ * project: string|null, rest: string[], error?: string }}
27
+ * `project` is the NAME in the path (null for tasks and bare `/projects`): the
28
+ * caller resolves it to an id. `rest` is what follows the project (projects) or
29
+ * the root (wiki/skills/tasks).
30
+ */
31
+ export function classifyPath(path) {
32
+ let segs = String(path || '').split('/').map((s) => s.trim()).filter(Boolean);
33
+ let org = null;
34
+ if (segs[0] === 'o' && segs.length >= 2) {
35
+ org = segs[1];
36
+ segs = segs.slice(2);
37
+ }
38
+ const { folder, rest } = splitFolderScope('/' + segs.join('/'));
39
+ const out = { org, folder: normalizeFolder(folder), root: null, surface: null, project: null, rest: [] };
40
+ if (out.folder) {
41
+ const bad = folderPathError(out.folder);
42
+ if (bad) return { ...out, error: bad };
43
+ }
44
+ if (!rest) return out;
45
+ const [root, ...tail] = rest.split('/').filter(Boolean);
46
+ out.root = root;
47
+ out.surface = SURFACE_OF_ROOT[root];
48
+ if (root === 'projects') {
49
+ out.project = tail[0] || null;
50
+ out.rest = tail.slice(1);
51
+ } else {
52
+ out.rest = tail;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Both ends of a move. A `to` that names no root is relative to the source
59
+ * (liftFolderScope does the same), so it gates the same scope. Identical
60
+ * targets collapse to one; a cross-project move yields two.
61
+ */
62
+ export function classifyMove(from, to) {
63
+ const src = classifyPath(from);
64
+ const dstRaw = to == null ? null : classifyPath(to);
65
+ const dst = dstRaw && dstRaw.root ? dstRaw : null;
66
+ const targets = [src];
67
+ if (dst && !sameTarget(src, dst)) targets.push(dst);
68
+ return { from: src, to: dst, targets };
69
+ }
70
+
71
+ function sameTarget(a, b) {
72
+ return a.surface === b.surface && a.folder === b.folder && a.project === b.project;
73
+ }
74
+
75
+ /**
76
+ * The scope chain a write at (folder, projectId) sits inside, outermost first:
77
+ * org, each folder prefix, then the project. 'eng/backend' -> org, eng, eng/backend.
78
+ *
79
+ * @returns {Array<{scope:'org'|'folder'|'project', folder?:string, projectId?:string}>}
80
+ */
81
+ export function buildScopeChain({ folder = '', projectId = null } = {}) {
82
+ const chain = [{ scope: 'org', folder: '' }];
83
+ const segs = normalizeFolder(folder).split('/').filter(Boolean);
84
+ for (let i = 0; i < segs.length; i++) {
85
+ chain.push({ scope: 'folder', folder: segs.slice(0, i + 1).join('/') });
86
+ }
87
+ if (projectId) chain.push({ scope: 'project', projectId });
88
+ return chain;
89
+ }
90
+
91
+ /** Human/receipt form, stored in skill_gate_events.scope_chain. */
92
+ export function formatScopeChain(chain) {
93
+ return (chain || [])
94
+ .map((n) => (n.scope === 'org' ? 'org' : n.scope === 'folder' ? n.folder : `project:${n.projectId}`))
95
+ .join(' > ');
96
+ }
97
+
98
+ export { ROOTS };