write-draft.mjs
170 lines 5.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Skill: write-draft
3 *
4 * Writes an agent-generated draft back to the vault for human review.
5 *
6 * Vault path convention (auto-dated, agent-stamped):
7 * vault/projects/<project>/drafts/<YYYY-MM-DD>-<kind>-<short-title-slug>.md
8 *
9 * Frontmatter we always inject (used by Hub UI to render a review queue):
10 * - status: 'pending' (pending | approved | rejected | published)
11 * - project: '<project>'
12 * - kind: '<kind>' (script | social | thumbnail | clip | blog | newsletter)
13 * - agent: '<agent-name>' (e.g. 'bornfree-script-writer')
14 * - generated_at: ISO8601 string
15 * - source_grounding: array of vault paths the agent read (style guide, positioning, etc.)
16 *
17 * Hard rules:
18 * - Refuses to overwrite an existing approved/published draft (only pending may be replaced).
19 * - Refuses path traversal attempts.
20 * - Always writes via Hub PUT (never the bridge directly).
21 *
22 * @param {ReturnType<import('./hub-client.mjs').createHubClient>} hub
23 * @param {{
24 * project: 'born-free' | 'store-free' | 'knowtation',
25 * kind: 'script' | 'social' | 'thumbnail' | 'clip' | 'blog' | 'newsletter',
26 * title: string,
27 * body: string,
28 * agent: string,
29 * sourceGrounding?: string[],
30 * extraFrontmatter?: Record<string, unknown>,
31 * now?: () => Date,
32 * }} args
33 * @returns {Promise<{ path: string, frontmatter: object, written: true }>}
34 */
35 import { assertProject } from './hub-client.mjs';
36
37 const ALLOWED_KINDS = /** @type {const} */ (['script', 'social', 'thumbnail', 'clip', 'blog', 'newsletter']);
38
39 export async function writeDraft(hub, args) {
40 const project = assertProject(args.project);
41 const kind = assertKind(args.kind);
42 const agent = sanitizeAgent(args.agent);
43 const title = sanitizeTitle(args.title);
44 const body = sanitizeBody(args.body);
45 const sourceGrounding = sanitizeSourceGrounding(args.sourceGrounding);
46 const extra = args.extraFrontmatter && typeof args.extraFrontmatter === 'object'
47 ? args.extraFrontmatter
48 : {};
49 const now = (args.now ? args.now() : new Date());
50
51 const datePrefix = isoDate(now);
52 const titleSlug = slugify(title);
53 const filename = `${datePrefix}-${kind}-${titleSlug}.md`;
54 const path = `projects/${project}/drafts/${filename}`;
55
56 // Refuse to overwrite an approved/published draft.
57 let existing = null;
58 try {
59 existing = await hub.getNote(path);
60 } catch (e) {
61 if (e.status !== 404) throw e;
62 }
63 if (existing && existing.frontmatter && typeof existing.frontmatter === 'object') {
64 const status = String(existing.frontmatter.status ?? '').toLowerCase();
65 if (status === 'approved' || status === 'published') {
66 throw Object.assign(
67 new Error(
68 `refuse_overwrite: vault/${path} already exists with status='${status}'. ` +
69 `Choose a different title or wait for the existing draft to be rejected.`
70 ),
71 { code: 'REFUSE_OVERWRITE', path, status }
72 );
73 }
74 }
75
76 const frontmatter = {
77 ...extra,
78 status: 'pending',
79 project,
80 kind,
81 agent,
82 title,
83 generated_at: now.toISOString(),
84 source_grounding: sourceGrounding,
85 };
86
87 await hub.putNote(path, {
88 frontmatter,
89 body,
90 });
91
92 return { path, frontmatter, written: true };
93 }
94
95 function assertKind(kind) {
96 if (!ALLOWED_KINDS.includes(kind)) {
97 throw Object.assign(
98 new Error(`unknown_kind: '${kind}' is not in [${ALLOWED_KINDS.join(', ')}]`),
99 { code: 'UNKNOWN_KIND' }
100 );
101 }
102 return kind;
103 }
104
105 function sanitizeAgent(agent) {
106 if (typeof agent !== 'string' || !agent.trim()) {
107 throw Object.assign(new Error('invalid_agent: agent must be a non-empty string'), {
108 code: 'INVALID_AGENT',
109 });
110 }
111 if (!/^[a-z0-9][a-z0-9_-]*$/i.test(agent)) {
112 throw Object.assign(
113 new Error(`invalid_agent: '${agent}' contains illegal chars; allowed: a-z, 0-9, -, _`),
114 { code: 'INVALID_AGENT' }
115 );
116 }
117 return agent;
118 }
119
120 function sanitizeTitle(title) {
121 if (typeof title !== 'string' || !title.trim()) {
122 throw Object.assign(new Error('invalid_title: title must be a non-empty string'), {
123 code: 'INVALID_TITLE',
124 });
125 }
126 if (title.length > 200) {
127 throw Object.assign(new Error('invalid_title: title exceeds 200 chars'), {
128 code: 'INVALID_TITLE',
129 });
130 }
131 return title.trim();
132 }
133
134 function sanitizeBody(body) {
135 if (typeof body !== 'string') {
136 throw Object.assign(new Error('invalid_body: body must be a string'), {
137 code: 'INVALID_BODY',
138 });
139 }
140 if (body.length > 200_000) {
141 throw Object.assign(new Error('invalid_body: body exceeds 200_000 chars'), {
142 code: 'INVALID_BODY',
143 });
144 }
145 return body;
146 }
147
148 function sanitizeSourceGrounding(arr) {
149 if (arr == null) return [];
150 if (!Array.isArray(arr)) {
151 throw Object.assign(new Error('invalid_source_grounding: must be array of vault paths'), {
152 code: 'INVALID_SOURCE_GROUNDING',
153 });
154 }
155 return arr
156 .map((p) => String(p))
157 .filter((p) => p.length > 0 && !p.includes('..') && !p.startsWith('/'));
158 }
159
160 function isoDate(d) {
161 return d.toISOString().slice(0, 10);
162 }
163
164 function slugify(s) {
165 return s
166 .toLowerCase()
167 .replace(/[^\p{L}\p{N}]+/gu, '-')
168 .replace(/^-+|-+$/g, '')
169 .slice(0, 60) || 'untitled';
170 }
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 1 day ago