drafted 1.24.3 → 1.24.5

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.
Files changed (46) hide show
  1. package/README.md +15 -34
  2. package/cli/drafted.mjs +1 -1
  3. package/mcp/chunked-write.mjs +178 -0
  4. package/mcp/server.mjs +114 -12
  5. package/package.json +23 -29
  6. package/skills/import-website-to-drafted.md +329 -0
  7. package/agent-instructions/global.md +0 -3
  8. package/cli/google-setup.sh +0 -186
  9. package/cli/mail-watch.mjs +0 -237
  10. package/cli/open-url.mjs +0 -17
  11. package/cli/repo-scan.mjs +0 -240
  12. package/cli/skill-setup-trust.mjs +0 -17
  13. package/install-mcp.sh +0 -1440
  14. package/mcp/active-project-store.mjs +0 -81
  15. package/mcp/gates.mjs +0 -206
  16. package/mcp/gws-status.mjs +0 -31
  17. package/mcp/repo-address.mjs +0 -215
  18. package/mcp/test-file-path-text.mjs +0 -58
  19. package/mcp/test-org-guards.mjs +0 -234
  20. package/mcp/test-project-index.mjs +0 -88
  21. package/mcp/test-search-terms.mjs +0 -79
  22. package/mcp/test-skill-paths.mjs +0 -29
  23. package/mcp/widgets/canvas-overview.html +0 -229
  24. package/mcp/widgets/frame-preview.html +0 -162
  25. package/plugin/commands/create-project.md +0 -20
  26. package/plugin/commands/create-skill.md +0 -18
  27. package/plugin/commands/extract.md +0 -16
  28. package/plugin/commands/improve-project-harness.md +0 -16
  29. package/plugin/commands/improve-skill.md +0 -14
  30. package/plugin/commands/improve-wiki.md +0 -14
  31. package/plugin/commands/ingest.md +0 -20
  32. package/plugin/commands/onboard-drafted.md +0 -18
  33. package/plugin/skills/drafted/SKILL.md +0 -102
  34. package/server/lib/umami.mjs +0 -164
  35. package/src/shared/anchor-scope.mjs +0 -98
  36. package/src/shared/excalidraw.mjs +0 -94
  37. package/src/shared/folder-path.mjs +0 -176
  38. package/src/shared/gate-budget.mjs +0 -30
  39. package/src/shared/minion-presets.mjs +0 -67
  40. package/src/shared/okf-log.mjs +0 -62
  41. package/src/shared/record-conformance.mjs +0 -167
  42. package/src/shared/root-route.mjs +0 -45
  43. package/src/shared/task-id.mjs +0 -54
  44. package/src/shared/test-excalidraw-merge.mjs +0 -52
  45. package/src/shared/wiki-excalidraw.mjs +0 -90
  46. /package/{src/shared → shared}/constants.mjs +0 -0
@@ -1,234 +0,0 @@
1
- // Regression check for the MCP org-ambiguity policy (DRAFT-36 "one rule").
2
- // Run: `node mcp/test-org-guards.mjs`. No framework — asserts the single pure
3
- // decision core that the org guard delegates to, for BOTH creates and forks.
4
- import assert from 'node:assert/strict';
5
- import { mkdtempSync } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { tmpdir } from 'node:os';
8
- import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg, splitOrgScope, stripUrlOrigin, rmScope, splitProjectTail } from './server.mjs';
9
- import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
10
-
11
- // One rule governs create AND fork (a fork is a create). A write proceeds when its
12
- // org is a real root — explicit org=, a bound/active project, or a single-org user's
13
- // only org. It refuses to GUESS only when the user is multi-org with nothing bound.
14
- assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
15
- assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
16
- assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
17
- assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
18
-
19
- // A remote session is NOT a root. Its session org is the org the connection INHERITED
20
- // (the user's default), not one anybody chose — that inheritance wrote a Beoflow-bound
21
- // agent's wiki page into Personal. Multi-org remote must name its org like anyone else;
22
- // single-org remote stays frictionless (covered by the orgCount:1 case above).
23
- assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), true, 'remote + multi-org → BLOCK (its session org is inherited, not chosen)');
24
- assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 1 }), false, 'remote + single org → allow');
25
- assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
26
- assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
27
-
28
- // The xcode-build incident state: multi-org + a bound project sticky from an
29
- // earlier, unrelated project(open). Under Reading A a fork is NOT hard-blocked —
30
- // it lands in the active project's org (a real root) and the response RECEIPT names
31
- // that org, so the fork is visible instead of silent. Create and fork agree here by
32
- // design (one rule); the fix for the surprise is the receipt, not a block.
33
- const incident = { orgCount: 3, boundOrgId: 'causeway', activeProjectId: 'p1' };
34
- assert.equal(projectlessMutationNeedsOrg(incident), false, 'incident state: fork allowed into the bound org — awareness comes from the receipt (Reading A)');
35
-
36
- // The genuinely ambiguous case still errors for a fork, exactly as for a create:
37
- assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org fork with nothing bound → BLOCK before any copy is created');
38
-
39
- // ── Persisted stdio state must not cross DATABASES ────────────────────────────
40
- // The 2026-07-31 incident: this repo registers a prod stdio MCP (drafted.live) and a
41
- // local-dev one (localhost:3477) that run in the SAME cwd. Keyed by cwd alone, the
42
- // local MCP's activeProject + boundOrgId were rehydrated by the prod MCP at boot, so
43
- // the prod session addressed every request to an org id that exists only in the local
44
- // dev DB: get_org reported `workingOrg {id: <local-uuid>, name: null}` in no membership
45
- // list, and project-less calls failed `not a member of org "<local-uuid>"`.
46
- const file = join(mkdtempSync(join(tmpdir(), 'drafted-state-')), 'mcp-state.json');
47
- const cwd = '/Users/x/GitHub/drafted.live';
48
- const PROD = 'https://drafted.live';
49
- const LOCAL = 'http://localhost:3477';
50
-
51
- savePersistedProject(
52
- { activeProjectId: 'local-project', boundOrgId: 'b111302a-local-db-only' },
53
- { file, cwd, serverUrl: LOCAL }
54
- );
55
- assert.equal(
56
- loadPersistedProject({ file, cwd, serverUrl: PROD }),
57
- null,
58
- 'prod MCP must NOT rehydrate the local dev MCP state saved in the same cwd'
59
- );
60
- assert.equal(
61
- loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.boundOrgId,
62
- 'b111302a-local-db-only',
63
- 'same server + same cwd still rehydrates (the feature this persistence exists for)'
64
- );
65
-
66
- savePersistedProject({ activeProjectId: 'prod-project', boundOrgId: 'org-prod' }, { file, cwd, serverUrl: PROD });
67
- assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project');
68
- assert.equal(
69
- loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.activeProjectId,
70
- 'local-project',
71
- 'the two servers keep independent entries for one cwd'
72
- );
73
-
74
- // A pre-fix (bare-cwd) entry carries no record of which server minted its ids, so it is
75
- // never adopted — that entry IS the poisoned state.
76
- savePersistedProject({ activeProjectId: 'legacy', boundOrgId: 'b111302a-local-db-only' }, { file, cwd });
77
- assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project',
78
- 'a legacy un-namespaced entry never leaks into a server-scoped read');
79
-
80
- // ── A non-member working org can never stay the silent default ────────────────
81
- const stale = 'b111302a-937c-489b-8cec-a422c853ce85';
82
- assert.equal(
83
- boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale }),
84
- true,
85
- 'server rejected the org WE addressed the request to → drop it and retry unaddressed'
86
- );
87
- assert.equal(
88
- boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale, hasExplicitOrg: true }),
89
- false,
90
- 'an explicit org= is the CALLER\'s address — surface the error, never silently drop it'
91
- );
92
- assert.equal(
93
- boundOrgRejected({ message: 'not a member of org "Drafted"', boundOrgId: stale }),
94
- false,
95
- 'a rejection naming a DIFFERENT org is not ours to heal'
96
- );
97
- assert.equal(boundOrgRejected({ message: 'Project not found', boundOrgId: stale }), false, 'unrelated error → no heal');
98
- assert.equal(boundOrgRejected({ message: `not a member of org "${stale}"` }), false, 'nothing bound → nothing to drop');
99
-
100
- // ── receiptOrg: the mutation receipt must name where the write LANDED ─────────
101
- // Regression for the sibling of the foreign-org bug: a UUID-addressed page
102
- // self-derives its org server-side, so echoing the session's working org made
103
- // `org:` disagree with the (correct) `url:` on a cross-org edit.
104
- {
105
- const ORGS = [
106
- { id: 'org-a', name: 'Alpha' },
107
- { id: 'org-b', name: 'Bravo' },
108
- ];
109
- const SESSION = { id: 'org-a', name: 'Alpha' };
110
-
111
- // resource lives in the session's own org → session context, unchanged
112
- assert.deepEqual(
113
- receiptOrg({ resourceOrgId: 'org-a', sessionOrg: SESSION, orgList: ORGS }),
114
- SESSION,
115
- 'same-org write should echo the session org',
116
- );
117
-
118
- // response carries no org (path-addressed) → fall back to session context
119
- assert.deepEqual(
120
- receiptOrg({ resourceOrgId: null, sessionOrg: SESSION, orgList: ORGS }),
121
- SESSION,
122
- 'no resource org should fall back to the session org',
123
- );
124
-
125
- // THE BUG: resource lives elsewhere → must name the resource's org, not the session's
126
- assert.deepEqual(
127
- receiptOrg({ resourceOrgId: 'org-b', sessionOrg: SESSION, orgList: ORGS }),
128
- { id: 'org-b', name: 'Bravo' },
129
- 'cross-org write must echo the org the write landed in',
130
- );
131
-
132
- // resource org not in the membership list → still name it, honestly, rather
133
- // than silently substituting the session org
134
- assert.deepEqual(
135
- receiptOrg({ resourceOrgId: 'org-z', sessionOrg: SESSION, orgList: ORGS }),
136
- { id: 'org-z', name: null },
137
- 'unknown resource org should be named with a null name, not swapped out',
138
- );
139
-
140
- // no session org at all (unbound) and a resource org present
141
- assert.deepEqual(
142
- receiptOrg({ resourceOrgId: 'org-b', sessionOrg: null, orgList: ORGS }),
143
- { id: 'org-b', name: 'Bravo' },
144
- 'unbound session should still name the resource org',
145
- );
146
- }
147
-
148
- // Shape A grammar: org is the top folder of the filesystem (/o/<org>/<root>/...).
149
- // The org segment is stripped for the root handlers and carried as the per-request
150
- // scope; bare roots stay accepted (backward compat, session working org).
151
- assert.deepEqual(splitOrgScope('/o/acme/wiki/engineering/authz.md'), { path: '/wiki/engineering/authz.md', org: 'acme' }, 'org-scoped wiki path strips to the bare root + org');
152
- assert.deepEqual(splitOrgScope('/o/acme/projects/design/beoflow/wireframes/x.html'), { path: '/projects/design/beoflow/wireframes/x.html', org: 'acme' }, 'org-scoped project path strips to the bare root + org');
153
- assert.deepEqual(splitOrgScope('/o/acme'), { path: '/', org: 'acme' }, 'ls /o/<org> lists that org\'s roots');
154
- assert.deepEqual(splitOrgScope('/o/acme/'), { path: '/', org: 'acme' }, 'trailing slash on the org root is harmless');
155
- assert.deepEqual(splitOrgScope('/o/acme%20brand/skills'), { path: '/skills', org: 'acme brand' }, 'org segment is URL-decoded');
156
- assert.deepEqual(splitOrgScope('/o/acme/tasks'), { path: '/tasks', org: 'acme' }, 'the tasks root is a root like any other');
157
- assert.deepEqual(splitOrgScope('/o/acme/tasks/sprint-1/ship-it.md'), { path: '/tasks/sprint-1/ship-it.md', org: 'acme' }, 'a lane + file under /tasks strips to the bare root + org');
158
- assert.deepEqual(splitOrgScope('/wiki/engineering'), { path: '/wiki/engineering', org: null }, 'bare root paths pass through untouched');
159
- assert.deepEqual(splitOrgScope('/projects/x/y/z.html'), { path: '/projects/x/y/z.html', org: null }, 'bare project paths pass through untouched');
160
- assert.ok(splitOrgScope('/o/').error, 'a bare /o/ is an invalid org-scoped path');
161
- assert.ok(splitOrgScope('/o/').error?.includes('expected /o/<org>/<root>'), 'error names the expected grammar');
162
-
163
- // Full share URLs (Q2): the URL's pathname IS the fs path — stripping the origin must
164
- // leave an addressable path, and non-URL inputs pass through untouched.
165
- assert.equal(stripUrlOrigin('https://drafted.live/o/acme/wiki/engineering/authz.md'), '/o/acme/wiki/engineering/authz.md', 'full URL strips to its pathname');
166
- assert.equal(stripUrlOrigin('https://drafted.live/o/acme/projects/beoflow/designs/pricing/hero.html?x=1'), '/o/acme/projects/beoflow/designs/pricing/hero.html', 'URL query params are dropped with the origin');
167
- assert.equal(stripUrlOrigin('/o/acme/wiki/x'), '/o/acme/wiki/x', 'plain paths pass through untouched');
168
- assert.equal(stripUrlOrigin('not a url'), 'not a url', 'non-URL input passes through');
169
- assert.equal(stripUrlOrigin('/f/00000000-0000-0000-0000-000000000000'), '/f/00000000-0000-0000-0000-000000000000', '/f/ frame links pass through');
170
-
171
- // fs(rm) scope: only a BARE project path may archive a project. A lane path
172
- // (/slides/deck) and a layer path (/slides) both parse with filename=null and
173
- // used to fall through to the archive branch, silently archiving the whole
174
- // project with a success-shaped response.
175
- assert.equal(rmScope('slides', 'deck', '12.html'), 'file', 'a full frame path removes that frame');
176
- assert.equal(rmScope('slides', null, '12.html'), 'file', 'a layer-root file path removes that frame');
177
- assert.equal(rmScope('slides', 'deck', null), 'directory', 'a LANE path must never archive the project');
178
- assert.equal(rmScope('slides', null, null), 'directory', 'a LAYER path must never archive the project');
179
- assert.equal(rmScope(null, null, null), 'project', 'only the bare project path archives the project');
180
-
181
- // fs(mv) reads the SAME three scopes, which is why they share one predicate: a
182
- // file path moves one frame, a lane/layer path moves the whole set, and a bare
183
- // project path renames the project. mv used to assume 'file' at every scope and
184
- // built "/slides/deck/null" for a lane — a 404 that read as "lanes cannot move".
185
- assert.equal(rmScope('slides', 'deck', null), 'directory', 'mv on a LANE path moves the lane, not a frame called null');
186
- assert.equal(rmScope('slides', null, null), 'directory', 'mv on a LAYER path moves the layer');
187
- assert.equal(rmScope(null, null, null), 'project', 'mv on a bare project path renames the project');
188
-
189
- // splitProjectTail: everything AFTER the project segment. It reads the tail only,
190
- // so the folder chain in front of the project cannot shift the layer/lane/file
191
- // the way the old positional `parts.length ===` branches did — that is what made
192
- // /projects/Marketing/beoflow-marketing answer "project not found: Marketing".
193
- {
194
- const t = (tail, expected, why) => assert.deepEqual(splitProjectTail(tail), expected, why);
195
- t([], { layer: undefined, lane: null, filename: null }, 'no tail = the project itself');
196
- t(['designs'], { layer: 'designs', lane: null, filename: null }, 'one segment = a layer');
197
- t(['designs', 'default'], { layer: 'designs', lane: 'default', filename: null }, 'no extension = a lane');
198
- t(['designs', 'a.html'], { layer: 'designs', lane: null, filename: 'a.html' }, 'an extension = a layer-root file');
199
- t(['designs', 'default', 'a.html'], { layer: 'designs', lane: 'default', filename: 'a.html' }, 'the full frame path');
200
- // A lane may legitimately be named after a layer; treating it as a file here
201
- // would send the read to /designs/designs and 404 on a lane that exists.
202
- t(['plans', 'designs'], { layer: 'plans', lane: 'designs', filename: null }, 'a layer NAME as the last segment is still a lane');
203
- }
204
-
205
- console.log('org-guard policy OK');
206
- // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
207
- // loop that keeps the event loop alive. Assertions are done — exit deterministically.
208
- // ── searchNeedsOrg: stricter than a mutation, on purpose ───────────
209
- // A mutation credits a bound project because its receipt names the destination.
210
- // A search has no such tell: scoping it from an unstated binding yields an empty
211
- // result the agent reads as "does not exist anywhere". Only a NAMED org counts.
212
- {
213
- const { searchNeedsOrg } = await import('./server.mjs');
214
- const t = (label, args, expected) => {
215
- const got = searchNeedsOrg(args);
216
- if (got !== expected) {
217
- console.error(`FAIL searchNeedsOrg: ${label} -> ${got}, expected ${expected}`);
218
- process.exitCode = 1;
219
- } else console.log(`ok searchNeedsOrg: ${label}`);
220
- };
221
-
222
- t('multi-org, nothing named -> REFUSE', { orgCount: 3 }, true);
223
- t('org in the path -> proceed', { orgFromPath: 'beoflow', orgCount: 3 }, false);
224
- t('explicit org= -> proceed', { explicitOrg: 'beoflow', orgCount: 3 }, false);
225
- t('single org -> proceed (nothing to be fenced from)', { orgCount: 1 }, false);
226
- t('zero/unknown orgs -> proceed, never block on unknown membership', { orgCount: 0 }, false);
227
- // The divergence from the mutation rule is the whole point: a binding does NOT
228
- // satisfy a search, because the caller never stated the scope.
229
- t('bound project does NOT satisfy a search', { activeProjectId: 'p1', boundOrgId: 'o1', orgCount: 2 }, true);
230
- }
231
-
232
- // Must exit: importing server.mjs opens the MCP WebSocket at module scope and
233
- // pins the event loop (AGENTS.md).
234
- process.exit(process.exitCode || 0);
@@ -1,88 +0,0 @@
1
- #!/usr/bin/env node
2
- // Regression for the `ls /projects` shape: the old response returned raw
3
- // /api/projects rows (~2.4KB each once `layers` is pretty-printed), so 180
4
- // projects blew the 90KB tool-result cap and truncated mid-JSON at 38.
5
- // These assert the two properties that keep that from recurring: a bounded,
6
- // line-per-project shape, and recency ordering so truncation drops the dead
7
- // tail rather than the live projects.
8
- //
9
- // Run: node mcp/test-project-index.mjs
10
- import assert from 'node:assert/strict';
11
- import { formatProjectIndex, projectPath } from './gates.mjs';
12
-
13
- const NOW = Date.UTC(2026, 7, 31);
14
- const day = 86400000;
15
-
16
- const mk = (n, over = {}) => ({
17
- id: `id-${n}`,
18
- name: `Project ${n}`,
19
- slug: `project-${n}`,
20
- orgSlug: 'acme',
21
- folder: null,
22
- frameCount: 3,
23
- createdAt: new Date(NOW - 400 * day).toISOString(),
24
- updatedAt: new Date(NOW - n * day).toISOString(),
25
- ...over,
26
- });
27
-
28
- // ── path shape ───────────────────────────────────────────────────────────────
29
- assert.equal(projectPath(mk(1)), '/o/acme/projects/project-1');
30
- assert.equal(projectPath(mk(1, { folder: 'clients' })), '/o/acme/projects/clients/project-1');
31
- assert.equal(projectPath({ orgId: 'org-uuid', id: 'p-uuid' }), '/o/org-uuid/projects/p-uuid');
32
-
33
- // ── empty ────────────────────────────────────────────────────────────────────
34
- assert.match(formatProjectIndex([]), /no projects/i);
35
-
36
- // ── the reported failure: 180 projects must stay far under the 90KB cap ──────
37
- const many = Array.from({ length: 180 }, (_, i) => mk(i + 1));
38
- const out = formatProjectIndex(many, { now: NOW });
39
- assert.ok(out.length < 10_000, `180 projects rendered ${out.length} chars, expected <10KB`);
40
- assert.match(out, /^180 projects/, 'header reports the true total, not the shown count');
41
- assert.match(out, /…and 130 more/, 'tail names what was withheld');
42
- assert.equal(out.split('\n').filter((l) => l.startsWith(' /o/')).length, 50, 'caps at 50 lines');
43
-
44
- // Recency order: newest-touched first, so the tail that gets cut is the stale end.
45
- const shownPaths = out.split('\n').filter((l) => l.startsWith(' /o/')).map((l) => l.trim().split(/\s+/)[0]);
46
- assert.equal(shownPaths[0], '/o/acme/projects/project-1', 'most recently touched leads');
47
- assert.equal(shownPaths[49], '/o/acme/projects/project-50');
48
- assert.ok(!out.includes('project-180'), 'stale tail is the part dropped');
49
-
50
- // updatedAt wins over createdAt even when createdAt ordering disagrees.
51
- const stale = mk(1, { slug: 'old-but-busy', createdAt: new Date(NOW - 900 * day).toISOString(), updatedAt: new Date(NOW - 1 * day).toISOString() });
52
- const fresh = mk(2, { slug: 'new-but-idle', createdAt: new Date(NOW - 2 * day).toISOString(), updatedAt: new Date(NOW - 300 * day).toISOString() });
53
- const ordered = formatProjectIndex([fresh, stale], { now: NOW });
54
- assert.ok(ordered.indexOf('old-but-busy') < ordered.indexOf('new-but-idle'), 'sorts on updatedAt, not createdAt');
55
-
56
- // Missing activity data must not crash or sort to the top.
57
- const noStats = formatProjectIndex([{ id: 'x', slug: 'bare', orgSlug: 'acme' }, mk(1)], { now: NOW });
58
- assert.match(noStats, /\/o\/acme\/projects\/bare/);
59
-
60
- // ── header carries the session binding, not a shared cursor ──────────────────
61
- const bound = formatProjectIndex([mk(1)], { boundPath: '/o/acme/projects/project-1', now: NOW });
62
- assert.match(bound, /bound: \/o\/acme\/projects\/project-1/);
63
- assert.ok(!formatProjectIndex([mk(1)], { now: NOW }).includes('bound:'), 'no binding, no bound line');
64
-
65
- // Multi-org listings say so; single-org ones stay quiet.
66
- const multi = formatProjectIndex([mk(1), mk(2, { orgSlug: 'globex' })], { now: NOW });
67
- assert.match(multi, /2 orgs/);
68
- assert.ok(!formatProjectIndex([mk(1), mk(2)], { now: NOW }).includes('orgs'));
69
-
70
- // ── ages ─────────────────────────────────────────────────────────────────────
71
- const ages = formatProjectIndex([
72
- mk(1, { slug: 'a', updatedAt: new Date(NOW).toISOString() }),
73
- mk(2, { slug: 'b', updatedAt: new Date(NOW - 3 * day).toISOString() }),
74
- mk(3, { slug: 'c', updatedAt: new Date(NOW - 21 * day).toISOString() }),
75
- mk(4, { slug: 'd', updatedAt: new Date(NOW - 90 * day).toISOString() }),
76
- ], { now: NOW });
77
- for (const [slug, age] of [['a', 'today'], ['b', '3d'], ['c', '3w'], ['d', '3mo']]) {
78
- assert.match(ages, new RegExp(`projects/${slug}\\s+3 frames\\s+${age}\\b`), `${slug} → ${age}`);
79
- }
80
-
81
- // Frame count reads as English, and absent counts render no column at all.
82
- assert.match(formatProjectIndex([mk(1, { frameCount: 1 })], { now: NOW }), /1 frame\s+1d/);
83
- assert.match(formatProjectIndex([mk(1, { frameCount: 0 })], { now: NOW }), /0 frames\s+1d/);
84
- const noCount = formatProjectIndex([mk(1, { frameCount: undefined })], { now: NOW });
85
- assert.ok(!noCount.includes('frame'), 'no frameCount, no frames column');
86
- assert.match(noCount, /1d$/m, 'age still renders without a count');
87
-
88
- console.log('ok — project index shape, ordering, bounds');
@@ -1,79 +0,0 @@
1
- // Regression for fs(search) term matching on /projects.
2
- // Run: `node mcp/test-search-terms.mjs`. No framework — asserts the pure core.
3
- //
4
- // Why this exists: search used to substring-test the WHOLE query against each
5
- // field, so `query="tool surface parity MCP Flow"` missed a project whose
6
- // description says "tool surface" and "parity" — while `query="parity"` hit it.
7
- // An agent reads that miss as "the project does not exist" and stops looking.
8
- import assert from 'node:assert/strict';
9
- import { matchesAllTerms, formatFrameHits, archivedNote, clip } from './server.mjs';
10
-
11
- const proj = [
12
- 'Flow AI MCP Server Feature Card',
13
- 'flow-ai-mcp-server-feature-card',
14
- 'Covers the BEO-2527 parity guard for the agent tool surface.',
15
- ];
16
-
17
- // The reported failure: a natural multi-word query spanning name AND description.
18
- assert.equal(matchesAllTerms(proj, 'tool surface parity MCP Flow'), true);
19
- // The single word that did work must keep working.
20
- assert.equal(matchesAllTerms(proj, 'parity'), true);
21
- // Terms match across fields and across a hyphenated slug.
22
- assert.equal(matchesAllTerms(proj, 'flow feature card'), true);
23
- assert.equal(matchesAllTerms(proj, 'BEO-2527'), true);
24
- // Case and surrounding whitespace are irrelevant.
25
- assert.equal(matchesAllTerms(proj, ' PARITY Guard '), true);
26
-
27
- // AND, not OR: one unmatched term is a miss. An OR would return most of the org.
28
- assert.equal(matchesAllTerms(proj, 'parity kubernetes'), false);
29
- assert.equal(matchesAllTerms(proj, 'kubernetes'), false);
30
-
31
- // An empty query never matches — the caller rejects it before this, and
32
- // "every term" over zero terms would otherwise be vacuously true for every row.
33
- assert.equal(matchesAllTerms(proj, ''), false);
34
- assert.equal(matchesAllTerms(proj, ' '), false);
35
- assert.equal(matchesAllTerms(proj, undefined), false);
36
-
37
- // Null/undefined fields don't throw or match a stray "null"/"undefined" term.
38
- assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'alpha'), true);
39
- assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'null'), false);
40
-
41
- // ── Frame hit rendering (docs/plans/frame-content-search.md, Phase 2) ────────
42
- const hit = {
43
- orgSlug: 'beoflow', projectSlug: 'flow-ai-mcp-server-feature-card',
44
- layer: 'plans', lane: 'phase-0', label: 'feature-plan.md',
45
- snippet: 'the BEO-2527 «parity guard» runs\n before every diff',
46
- };
47
- const rendered = formatFrameHits([hit]);
48
- assert.match(rendered, /\/o\/beoflow\/projects\/flow-ai-mcp-server-feature-card\/plans\/phase-0\/feature-plan\.md/);
49
- // The snippet is the evidence — it must survive, with its newlines flattened so
50
- // one hit stays one block instead of wrapping into the next path.
51
- assert.match(rendered, /«parity guard» runs before every diff/);
52
- assert.equal(rendered.split('\n').length, 2);
53
-
54
- // A hit with no snippet matched the LABEL, not content. Say so rather than
55
- // rendering a bare path that looks identical to a content match.
56
- assert.match(formatFrameHits([{ ...hit, snippet: null }]), /\(label match\)$/);
57
- assert.match(formatFrameHits([{ ...hit, snippet: ' ' }]), /\(label match\)$/);
58
-
59
- // A lane-less frame must not render a doubled slash — that path is not addressable.
60
- assert.ok(!formatFrameHits([{ ...hit, lane: null }]).includes('//'));
61
-
62
- // The cap is a cap; nothing renders beyond it.
63
- assert.equal(formatFrameHits(Array(40).fill({ ...hit, snippet: null }), { limit: 3 }).split('\n').length, 3);
64
-
65
- // Archived matches are counted, never silently dropped — and zero says nothing.
66
- assert.equal(archivedNote(0), '');
67
- assert.match(archivedNote(2), /2 more in archived projects/);
68
-
69
- // A result is a pointer, not the document: long fields are clipped so 25 hits
70
- // stay readable in-band instead of spilling to a file.
71
- assert.equal(clip('short', 20), 'short');
72
- assert.equal(clip('x'.repeat(50), 20).length, 20);
73
- assert.match(clip('x'.repeat(50), 20), /…$/);
74
- assert.equal(clip(null, 20), '');
75
- const long = formatFrameHits([{ ...hit, snippet: 'w '.repeat(400) }], { snippetChars: 60 });
76
- assert.ok(long.split('\n')[1].length <= 60 + 6, 'snippet line stays within its cap + indent');
77
-
78
- console.log('search term matching + frame hit rendering ok');
79
- process.exit(0);
@@ -1,29 +0,0 @@
1
- // Regression for the /skills path split used by fs(ls|read|write|rm).
2
- // Run: `node mcp/test-skill-paths.mjs`. No framework — asserts the pure core.
3
- //
4
- // Why this exists: a skill is a directory. Before this split the handler kept
5
- // only the first segment, so `fs(write, path="/skills/<slug>/README.md")` wrote
6
- // SKILL.md instead — the agent's README landed on top of the skill body and the
7
- // create-time README gate stayed unsatisfiable. isSkillMd is the load-bearing
8
- // bit: true routes to the skills row, false routes to skill_files.
9
- import assert from 'node:assert/strict';
10
- import { splitSkillPath } from './server.mjs';
11
-
12
- // Root and bare slug — no file, so the skill row itself.
13
- assert.deepEqual(splitSkillPath('/skills'), { slug: '', filePath: '', isSkillMd: true });
14
- assert.deepEqual(splitSkillPath('/skills/'), { slug: '', filePath: '', isSkillMd: true });
15
- assert.deepEqual(splitSkillPath('/skills/my-skill'), { slug: 'my-skill', filePath: '', isSkillMd: true });
16
- assert.deepEqual(splitSkillPath('/skills/my-skill/'), { slug: 'my-skill', filePath: '', isSkillMd: true });
17
-
18
- // SKILL.md is the skill body, addressed explicitly — same destination as the bare slug.
19
- assert.deepEqual(splitSkillPath('/skills/my-skill/SKILL.md'), { slug: 'my-skill', filePath: 'SKILL.md', isSkillMd: true });
20
-
21
- // Everything else is a supporting file, and the subpath survives.
22
- assert.deepEqual(splitSkillPath('/skills/my-skill/README.md'), { slug: 'my-skill', filePath: 'README.md', isSkillMd: false });
23
- assert.deepEqual(splitSkillPath('/skills/my-skill/references/api.md'), { slug: 'my-skill', filePath: 'references/api.md', isSkillMd: false });
24
-
25
- // Case matters: readme.md is a different file from README.md, and neither is SKILL.md.
26
- assert.equal(splitSkillPath('/skills/s/skill.md').isSkillMd, false);
27
-
28
- console.log('skill path split ok — SKILL.md routes to the skill row, everything else to skill files');
29
- process.exit(0);