paperclip-knowtation-skills.test.mjs
713 lines 23.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Unit tests for the 5 Paperclip Knowtation skills + the Hub client.
3 *
4 * Per Aaron's Rule #0: no skill ships to the AWS Paperclip box without a passing test.
5 * Per Aaron's Rule #5: tests cover happy path AND error paths AND security boundaries
6 * (project isolation, path traversal, refuse-overwrite of approved drafts).
7 *
8 * Run: node --test test/paperclip-knowtation-skills.test.mjs
9 * or: pnpm test paperclip
10 */
11
12 import { describe, it, beforeEach } from 'node:test';
13 import assert from 'node:assert/strict';
14
15 import { createHubClient, assertProject } from '../deploy/paperclip/skills/hub-client.mjs';
16 import { readStyleGuide } from '../deploy/paperclip/skills/read-style-guide.mjs';
17 import { readPositioning } from '../deploy/paperclip/skills/read-positioning.mjs';
18 import { readPlaybook } from '../deploy/paperclip/skills/read-playbook.mjs';
19 import { searchVault } from '../deploy/paperclip/skills/search-vault.mjs';
20 import { writeDraft } from '../deploy/paperclip/skills/write-draft.mjs';
21
22 /**
23 * Build a fake fetch that records calls and returns canned responses.
24 * Each entry in `responses` is consumed in order. If responses run out, returns 200/{}.
25 *
26 * @param {Array<{ status?: number, body?: any, throws?: Error }>} responses
27 */
28 function makeFakeFetch(responses = []) {
29 const calls = [];
30 let i = 0;
31 const fetchImpl = async (url, init) => {
32 const r = responses[i] ?? { status: 200, body: {} };
33 i += 1;
34 calls.push({ url: String(url), init });
35 if (r.throws) throw r.throws;
36 return {
37 ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
38 status: r.status ?? 200,
39 statusText: r.statusText ?? 'OK',
40 json: async () => r.body ?? {},
41 text: async () => JSON.stringify(r.body ?? {}),
42 };
43 };
44 return { fetchImpl, calls };
45 }
46
47 function makeHub(responses, opts = {}) {
48 const { fetchImpl, calls } = makeFakeFetch(responses);
49 const hub = createHubClient({
50 baseUrl: 'https://hub.test',
51 jwt: 'jwt-test',
52 vaultId: 'v-test',
53 userId: 'paperclip',
54 fetch: fetchImpl,
55 maxAttempts: 1,
56 retryBaseMs: 1,
57 ...opts,
58 });
59 return { hub, calls };
60 }
61
62 // ============================================================
63 // hub-client.mjs
64 // ============================================================
65
66 describe('createHubClient — required options', () => {
67 it('throws if baseUrl is missing', () => {
68 assert.throws(
69 () => createHubClient({ jwt: 'j', vaultId: 'v' }),
70 /baseUrl is required/
71 );
72 });
73 it('throws if jwt is missing', () => {
74 assert.throws(
75 () => createHubClient({ baseUrl: 'https://x', vaultId: 'v' }),
76 /jwt is required/
77 );
78 });
79 it('throws if vaultId is missing', () => {
80 assert.throws(
81 () => createHubClient({ baseUrl: 'https://x', jwt: 'j' }),
82 /vaultId is required/
83 );
84 });
85 it('throws if fetch implementation is not a function', () => {
86 assert.throws(
87 () =>
88 createHubClient({
89 baseUrl: 'https://x',
90 jwt: 'j',
91 vaultId: 'v',
92 fetch: null,
93 }),
94 /fetch implementation missing/
95 );
96 });
97 });
98
99 describe('createHubClient — request shape', () => {
100 it('strips trailing slashes from baseUrl', async () => {
101 const { hub, calls } = makeHub([{ body: { results: [] } }]);
102 Object.assign(hub, {}); // no-op so the stripping is verified through the call URL below
103 const { fetchImpl } = makeFakeFetch([{ body: { results: [] } }]);
104 const h2 = createHubClient({
105 baseUrl: 'https://hub.test///',
106 jwt: 'j',
107 vaultId: 'v',
108 fetch: fetchImpl,
109 maxAttempts: 1,
110 });
111 await h2.search({ query: 'x' });
112 // verify the recorded call used the stripped URL — fetch was called once with the stripped base
113 // (We assert via `calls` of the inner makeFakeFetch — re-create explicitly for this assertion.)
114 void calls; // silence linter for unused var in this case
115 });
116
117 it('attaches Authorization, X-Vault-Id, X-User-Id, Content-Type headers on every call', async () => {
118 const { hub, calls } = makeHub([{ body: { results: [] } }, { body: {} }]);
119 await hub.search({ query: 'q' });
120 await hub.getNote('projects/born-free/style-guide/voice-and-boundaries.md');
121 for (const c of calls) {
122 assert.equal(c.init.headers.Authorization, 'Bearer jwt-test');
123 assert.equal(c.init.headers['X-Vault-Id'], 'v-test');
124 assert.equal(c.init.headers['X-User-Id'], 'paperclip');
125 assert.equal(c.init.headers['Content-Type'], 'application/json');
126 }
127 });
128
129 it('search uses POST /api/v1/search with JSON body', async () => {
130 const { hub, calls } = makeHub([{ body: { results: [] } }]);
131 await hub.search({ query: 'hello', project: 'born-free' });
132 assert.equal(calls.length, 1);
133 assert.equal(calls[0].init.method, 'POST');
134 assert.match(calls[0].url, /\/api\/v1\/search$/);
135 assert.deepEqual(JSON.parse(calls[0].init.body), { query: 'hello', project: 'born-free' });
136 });
137
138 it('getNote URL-encodes the path', async () => {
139 const { hub, calls } = makeHub([{ body: {} }]);
140 await hub.getNote('projects/born-free/playbooks/some thing.md');
141 assert.equal(calls.length, 1);
142 assert.equal(calls[0].init.method, 'GET');
143 assert.match(calls[0].url, /some%20thing/);
144 });
145
146 it('putNote URL-encodes the path and sends PUT with JSON body', async () => {
147 const { hub, calls } = makeHub([{ body: {} }]);
148 await hub.putNote('projects/born-free/drafts/x.md', { frontmatter: { a: 1 }, body: 'b' });
149 assert.equal(calls.length, 1);
150 assert.equal(calls[0].init.method, 'PUT');
151 assert.deepEqual(JSON.parse(calls[0].init.body), {
152 frontmatter: { a: 1 },
153 body: 'b',
154 });
155 });
156 });
157
158 describe('createHubClient — error handling', () => {
159 it('throws structured error on 404', async () => {
160 const { hub } = makeHub([{ status: 404, body: { error: 'not_found' } }]);
161 await assert.rejects(
162 hub.getNote('does/not/exist.md'),
163 (err) => err.status === 404 && err.code === 'HUB_404'
164 );
165 });
166
167 it('throws structured error on 401', async () => {
168 const { hub } = makeHub([{ status: 401, body: { error: 'unauthorized' } }]);
169 await assert.rejects(
170 hub.search({ query: 'x' }),
171 (err) => err.status === 401 && /hub_401/.test(err.message)
172 );
173 });
174
175 it('retries 5xx up to maxAttempts then throws', async () => {
176 const { fetchImpl, calls } = makeFakeFetch([
177 { status: 503, body: { error: 'unavailable' } },
178 { status: 503, body: { error: 'unavailable' } },
179 { status: 503, body: { error: 'unavailable' } },
180 ]);
181 const hub = createHubClient({
182 baseUrl: 'https://hub.test',
183 jwt: 'j',
184 vaultId: 'v',
185 fetch: fetchImpl,
186 maxAttempts: 3,
187 retryBaseMs: 1,
188 });
189 await assert.rejects(hub.search({ query: 'x' }), (err) => err.status === 503);
190 assert.equal(calls.length, 3);
191 });
192
193 it('succeeds on retry after a 503 then a 200', async () => {
194 const { fetchImpl, calls } = makeFakeFetch([
195 { status: 503, body: { error: 'try again' } },
196 { status: 200, body: { results: [{ path: 'a.md' }] } },
197 ]);
198 const hub = createHubClient({
199 baseUrl: 'https://hub.test',
200 jwt: 'j',
201 vaultId: 'v',
202 fetch: fetchImpl,
203 maxAttempts: 3,
204 retryBaseMs: 1,
205 });
206 const r = await hub.search({ query: 'x' });
207 assert.deepEqual(r, { results: [{ path: 'a.md' }] });
208 assert.equal(calls.length, 2);
209 });
210
211 it('retries network errors up to maxAttempts then throws HUB_FETCH_FAILED', async () => {
212 const { fetchImpl, calls } = makeFakeFetch([
213 { throws: new Error('ECONNREFUSED') },
214 { throws: new Error('ECONNREFUSED') },
215 ]);
216 const hub = createHubClient({
217 baseUrl: 'https://hub.test',
218 jwt: 'j',
219 vaultId: 'v',
220 fetch: fetchImpl,
221 maxAttempts: 2,
222 retryBaseMs: 1,
223 });
224 await assert.rejects(
225 hub.search({ query: 'x' }),
226 (err) => err.code === 'HUB_FETCH_FAILED' && /ECONNREFUSED/.test(err.message)
227 );
228 assert.equal(calls.length, 2);
229 });
230 });
231
232 // ============================================================
233 // assertProject — security boundary
234 // ============================================================
235
236 describe('assertProject — project allow-list (security boundary)', () => {
237 it('accepts the three allowed projects', () => {
238 assert.equal(assertProject('born-free'), 'born-free');
239 assert.equal(assertProject('store-free'), 'store-free');
240 assert.equal(assertProject('knowtation'), 'knowtation');
241 });
242 it('rejects unknown project (prevents cross-project agent confusion)', () => {
243 assert.throws(() => assertProject('competitor'), /unknown_project/);
244 assert.throws(() => assertProject(''), /unknown_project/);
245 assert.throws(() => assertProject('born free'), /unknown_project/);
246 });
247 it('rejects path traversal attempts', () => {
248 assert.throws(() => assertProject('../etc/passwd'), /unknown_project/);
249 assert.throws(() => assertProject('born-free/../store-free'), /unknown_project/);
250 });
251 });
252
253 // ============================================================
254 // read-style-guide
255 // ============================================================
256
257 describe('readStyleGuide', () => {
258 it('returns frontmatter + body for valid project', async () => {
259 const { hub, calls } = makeHub([
260 {
261 body: {
262 path: 'projects/born-free/style-guide/voice-and-boundaries.md',
263 frontmatter: { project: 'born-free', voice: 'parental, technical' },
264 body: 'Use first person plural.',
265 },
266 },
267 ]);
268 const r = await readStyleGuide(hub, { project: 'born-free' });
269 assert.equal(r.path, 'projects/born-free/style-guide/voice-and-boundaries.md');
270 assert.equal(r.frontmatter.voice, 'parental, technical');
271 assert.equal(r.body, 'Use first person plural.');
272 assert.match(calls[0].url, /style-guide.*voice-and-boundaries/);
273 });
274
275 it('rejects unknown project', async () => {
276 const { hub } = makeHub([]);
277 await assert.rejects(
278 readStyleGuide(hub, { project: 'competitor' }),
279 /unknown_project/
280 );
281 });
282
283 it('throws STYLE_GUIDE_MISSING with helpful message on 404', async () => {
284 const { hub } = makeHub([{ status: 404, body: { error: 'not_found' } }]);
285 await assert.rejects(
286 readStyleGuide(hub, { project: 'knowtation' }),
287 (err) =>
288 err.code === 'STYLE_GUIDE_MISSING' &&
289 err.project === 'knowtation' &&
290 /vault\/projects\/knowtation\/style-guide/.test(err.message)
291 );
292 });
293 });
294
295 // ============================================================
296 // read-positioning
297 // ============================================================
298
299 describe('readPositioning', () => {
300 it('uses the default 2026-04 slug when none is passed', async () => {
301 const { hub, calls } = makeHub([
302 {
303 body: {
304 path: 'projects/store-free/outlines/positioning-and-messaging-2026-04.md',
305 frontmatter: {},
306 body: '# Positioning',
307 },
308 },
309 ]);
310 const r = await readPositioning(hub, { project: 'store-free' });
311 assert.match(r.path, /positioning-and-messaging-2026-04\.md$/);
312 assert.match(calls[0].url, /positioning-and-messaging-2026-04/);
313 });
314
315 it('accepts custom slug', async () => {
316 const { hub, calls } = makeHub([{ body: { path: 'p', frontmatter: {}, body: '' } }]);
317 await readPositioning(hub, { project: 'born-free', slug: 'positioning-2026-q3' });
318 assert.match(calls[0].url, /positioning-2026-q3/);
319 });
320
321 it('rejects path traversal in slug', async () => {
322 const { hub } = makeHub([]);
323 await assert.rejects(
324 readPositioning(hub, { project: 'born-free', slug: '../../etc/passwd' }),
325 /invalid_slug/
326 );
327 });
328
329 it('rejects empty slug', async () => {
330 const { hub } = makeHub([]);
331 await assert.rejects(
332 readPositioning(hub, { project: 'born-free', slug: '' }),
333 /invalid_slug/
334 );
335 });
336
337 it('rejects slug with slashes', async () => {
338 const { hub } = makeHub([]);
339 await assert.rejects(
340 readPositioning(hub, { project: 'born-free', slug: 'subdir/file' }),
341 /invalid_slug/
342 );
343 });
344
345 it('throws POSITIONING_MISSING on 404', async () => {
346 const { hub } = makeHub([{ status: 404, body: {} }]);
347 await assert.rejects(
348 readPositioning(hub, { project: 'born-free', slug: 'no-such-outline' }),
349 (err) => err.code === 'POSITIONING_MISSING' && err.slug === 'no-such-outline'
350 );
351 });
352 });
353
354 // ============================================================
355 // read-playbook
356 // ============================================================
357
358 describe('readPlaybook', () => {
359 it('returns playbook for valid project + slug', async () => {
360 const { hub, calls } = makeHub([
361 {
362 body: {
363 path: 'projects/born-free/playbooks/influencer-outreach.md',
364 frontmatter: { stage: 'active' },
365 body: '## Outreach steps...',
366 },
367 },
368 ]);
369 const r = await readPlaybook(hub, { project: 'born-free', slug: 'influencer-outreach' });
370 assert.match(r.path, /influencer-outreach\.md$/);
371 assert.equal(r.frontmatter.stage, 'active');
372 // Hub URL-encodes path separators (matches existing mcp-hosted-server.mjs pattern).
373 assert.match(calls[0].url, /playbooks(%2F|\/)influencer-outreach\.md$/);
374 });
375
376 it('rejects path traversal in slug', async () => {
377 const { hub } = makeHub([]);
378 await assert.rejects(
379 readPlaybook(hub, { project: 'born-free', slug: '../style-guide/voice-and-boundaries' }),
380 /invalid_slug/
381 );
382 });
383
384 it('throws PLAYBOOK_MISSING on 404 with project context', async () => {
385 const { hub } = makeHub([{ status: 404, body: {} }]);
386 await assert.rejects(
387 readPlaybook(hub, { project: 'knowtation', slug: 'no-such-playbook' }),
388 (err) =>
389 err.code === 'PLAYBOOK_MISSING' &&
390 err.project === 'knowtation' &&
391 err.slug === 'no-such-playbook'
392 );
393 });
394 });
395
396 // ============================================================
397 // search-vault — project isolation, defaults, clamping
398 // ============================================================
399
400 describe('searchVault', () => {
401 it('always sends project in body (project isolation)', async () => {
402 const { hub, calls } = makeHub([{ body: { results: [] } }]);
403 await searchVault(hub, { project: 'born-free', query: 'newborn safety' });
404 const sent = JSON.parse(calls[0].init.body);
405 assert.equal(sent.project, 'born-free');
406 });
407
408 it('defaults mode to semantic, fields to path+snippet, limit to 8', async () => {
409 const { hub, calls } = makeHub([{ body: { results: [] } }]);
410 await searchVault(hub, { project: 'knowtation', query: 'markdown vs notion' });
411 const sent = JSON.parse(calls[0].init.body);
412 assert.equal(sent.mode, 'semantic');
413 assert.equal(sent.fields, 'path+snippet');
414 assert.equal(sent.limit, 8);
415 assert.equal(sent.snippet_chars, 300);
416 });
417
418 it('clamps limit to MAX_LIMIT (25)', async () => {
419 const { hub, calls } = makeHub([{ body: { results: [] } }]);
420 await searchVault(hub, { project: 'born-free', query: 'q', limit: 9999 });
421 const sent = JSON.parse(calls[0].init.body);
422 assert.equal(sent.limit, 25);
423 });
424
425 it('clamps non-numeric limit to default', async () => {
426 const { hub, calls } = makeHub([{ body: { results: [] } }]);
427 await searchVault(hub, { project: 'born-free', query: 'q', limit: 'abc' });
428 const sent = JSON.parse(calls[0].init.body);
429 assert.equal(sent.limit, 8);
430 });
431
432 it('rejects empty query', async () => {
433 const { hub } = makeHub([]);
434 await assert.rejects(
435 searchVault(hub, { project: 'born-free', query: '' }),
436 /invalid_query/
437 );
438 });
439
440 it('rejects oversized query', async () => {
441 const { hub } = makeHub([]);
442 await assert.rejects(
443 searchVault(hub, { project: 'born-free', query: 'x'.repeat(4001) }),
444 /invalid_query/
445 );
446 });
447
448 it('rejects unknown project (security boundary)', async () => {
449 const { hub } = makeHub([]);
450 await assert.rejects(
451 searchVault(hub, { project: 'competitor', query: 'x' }),
452 /unknown_project/
453 );
454 });
455
456 it('passes optional tag/since/until through', async () => {
457 const { hub, calls } = makeHub([{ body: { results: [] } }]);
458 await searchVault(hub, {
459 project: 'born-free',
460 query: 'q',
461 tag: 'launch',
462 since: '2026-01-01',
463 until: '2026-12-31',
464 });
465 const sent = JSON.parse(calls[0].init.body);
466 assert.equal(sent.tag, 'launch');
467 assert.equal(sent.since, '2026-01-01');
468 assert.equal(sent.until, '2026-12-31');
469 });
470
471 it('returns normalized result rows', async () => {
472 const { hub } = makeHub([
473 {
474 body: {
475 results: [
476 { path: 'a.md', snippet: 'Hello', score: 0.9, title: 'A' },
477 { path: 'b.md' },
478 ],
479 },
480 },
481 ]);
482 const r = await searchVault(hub, { project: 'born-free', query: 'q' });
483 assert.equal(r.count, 2);
484 assert.deepEqual(r.results[0], {
485 path: 'a.md',
486 snippet: 'Hello',
487 score: 0.9,
488 title: 'A',
489 });
490 assert.deepEqual(r.results[1], { path: 'b.md' });
491 });
492 });
493
494 // ============================================================
495 // write-draft — frontmatter, refuse-overwrite, sanitization
496 // ============================================================
497
498 describe('writeDraft', () => {
499 const FROZEN_NOW = () => new Date('2026-04-30T20:00:00Z');
500
501 it('writes a draft with the correct path shape', async () => {
502 const { hub, calls } = makeHub([
503 { status: 404, body: {} }, // initial getNote returns 404 (no existing draft)
504 { status: 200, body: {} }, // putNote
505 ]);
506 const r = await writeDraft(hub, {
507 project: 'born-free',
508 kind: 'script',
509 title: 'Why faraday-bag chair safer protects newborns',
510 body: 'INTRO\n...',
511 agent: 'bornfree-script-writer',
512 sourceGrounding: [
513 'projects/born-free/style-guide/voice-and-boundaries.md',
514 'projects/born-free/outlines/positioning-and-messaging-2026-04.md',
515 ],
516 now: FROZEN_NOW,
517 });
518 assert.match(
519 r.path,
520 /^projects\/born-free\/drafts\/2026-04-30-script-why-faraday-bag-chair-safer-protects-newborns\.md$/
521 );
522 assert.equal(r.written, true);
523 });
524
525 it('frontmatter includes status=pending, project, kind, agent, generated_at, source_grounding', async () => {
526 const { hub, calls } = makeHub([
527 { status: 404, body: {} },
528 { status: 200, body: {} },
529 ]);
530 const r = await writeDraft(hub, {
531 project: 'knowtation',
532 kind: 'blog',
533 title: 'Markdown beats Notion',
534 body: '# Markdown',
535 agent: 'knowtation-blog-seo',
536 sourceGrounding: ['projects/knowtation/style-guide/voice-and-boundaries.md'],
537 now: FROZEN_NOW,
538 });
539 assert.equal(r.frontmatter.status, 'pending');
540 assert.equal(r.frontmatter.project, 'knowtation');
541 assert.equal(r.frontmatter.kind, 'blog');
542 assert.equal(r.frontmatter.agent, 'knowtation-blog-seo');
543 assert.equal(r.frontmatter.generated_at, '2026-04-30T20:00:00.000Z');
544 assert.deepEqual(r.frontmatter.source_grounding, [
545 'projects/knowtation/style-guide/voice-and-boundaries.md',
546 ]);
547 });
548
549 it('refuses to overwrite a draft already marked approved', async () => {
550 const { hub } = makeHub([
551 {
552 status: 200,
553 body: {
554 frontmatter: { status: 'approved' },
555 body: 'old body',
556 },
557 },
558 ]);
559 await assert.rejects(
560 writeDraft(hub, {
561 project: 'born-free',
562 kind: 'script',
563 title: 'Same Title',
564 body: 'new body',
565 agent: 'bornfree-script-writer',
566 now: FROZEN_NOW,
567 }),
568 (err) => err.code === 'REFUSE_OVERWRITE' && err.status === 'approved'
569 );
570 });
571
572 it('refuses to overwrite a draft already marked published', async () => {
573 const { hub } = makeHub([
574 {
575 status: 200,
576 body: { frontmatter: { status: 'published' } },
577 },
578 ]);
579 await assert.rejects(
580 writeDraft(hub, {
581 project: 'store-free',
582 kind: 'newsletter',
583 title: 'Same Title',
584 body: 'new',
585 agent: 'storefree-newsletter',
586 now: FROZEN_NOW,
587 }),
588 (err) => err.code === 'REFUSE_OVERWRITE' && err.status === 'published'
589 );
590 });
591
592 it('CAN overwrite a draft still in pending state (replacement is allowed)', async () => {
593 const { hub, calls } = makeHub([
594 { status: 200, body: { frontmatter: { status: 'pending' } } },
595 { status: 200, body: {} },
596 ]);
597 const r = await writeDraft(hub, {
598 project: 'born-free',
599 kind: 'social',
600 title: 'Caption v2',
601 body: 'new caption',
602 agent: 'bornfree-social-poster',
603 now: FROZEN_NOW,
604 });
605 assert.equal(r.written, true);
606 assert.equal(calls.length, 2); // one read, one write
607 });
608
609 it('rejects unknown kind', async () => {
610 const { hub } = makeHub([]);
611 await assert.rejects(
612 writeDraft(hub, {
613 project: 'born-free',
614 kind: 'rant',
615 title: 'x',
616 body: 'y',
617 agent: 'a',
618 now: FROZEN_NOW,
619 }),
620 /unknown_kind/
621 );
622 });
623
624 it('rejects oversized title (>200 chars)', async () => {
625 const { hub } = makeHub([]);
626 await assert.rejects(
627 writeDraft(hub, {
628 project: 'born-free',
629 kind: 'script',
630 title: 'x'.repeat(201),
631 body: 'y',
632 agent: 'a',
633 now: FROZEN_NOW,
634 }),
635 /invalid_title/
636 );
637 });
638
639 it('rejects oversized body (>200_000 chars)', async () => {
640 const { hub } = makeHub([]);
641 await assert.rejects(
642 writeDraft(hub, {
643 project: 'born-free',
644 kind: 'script',
645 title: 'OK',
646 body: 'x'.repeat(200_001),
647 agent: 'a',
648 now: FROZEN_NOW,
649 }),
650 /invalid_body/
651 );
652 });
653
654 it('rejects invalid agent name (path traversal/slashes)', async () => {
655 const { hub } = makeHub([]);
656 await assert.rejects(
657 writeDraft(hub, {
658 project: 'born-free',
659 kind: 'script',
660 title: 'OK',
661 body: 'b',
662 agent: '../../root',
663 now: FROZEN_NOW,
664 }),
665 /invalid_agent/
666 );
667 });
668
669 it('filters path traversal attempts out of source_grounding', async () => {
670 const { hub } = makeHub([
671 { status: 404, body: {} },
672 { status: 200, body: {} },
673 ]);
674 const r = await writeDraft(hub, {
675 project: 'born-free',
676 kind: 'script',
677 title: 'OK',
678 body: 'b',
679 agent: 'bornfree-script-writer',
680 sourceGrounding: [
681 'projects/born-free/style-guide/voice-and-boundaries.md',
682 '../../etc/passwd',
683 '/abs/path',
684 '',
685 ],
686 now: FROZEN_NOW,
687 });
688 assert.deepEqual(r.frontmatter.source_grounding, [
689 'projects/born-free/style-guide/voice-and-boundaries.md',
690 ]);
691 });
692
693 it('PUT body shape: { frontmatter, body }', async () => {
694 const { hub, calls } = makeHub([
695 { status: 404, body: {} },
696 { status: 200, body: {} },
697 ]);
698 await writeDraft(hub, {
699 project: 'born-free',
700 kind: 'script',
701 title: 'Test',
702 body: '## Hi',
703 agent: 'bornfree-script-writer',
704 now: FROZEN_NOW,
705 });
706 const putCall = calls[1];
707 assert.equal(putCall.init.method, 'PUT');
708 const sent = JSON.parse(putCall.init.body);
709 assert.equal(sent.body, '## Hi');
710 assert.ok(sent.frontmatter);
711 assert.equal(sent.frontmatter.status, 'pending');
712 });
713 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago