projection-generator.mjs
514 lines 15.0 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 14 hours ago
1 /**
2 * Pure Flow projection generator (Phase 7A-11b).
3 *
4 * Renders canonical knowtation.flow/v0 + steps into knowtation.flow_projection/v0 artifacts.
5 * Deterministic over (flow, steps, harness, PROJECTION_GENERATOR_VERSION); no I/O, no network.
6 *
7 * @see docs/FLOW-PROJECTION-GENERATOR-CONTRACT-7A-11.md §1
8 */
9
10 import { createHash } from 'crypto';
11 import { parseSemver, compareSemver } from './flow-store.mjs';
12
13 export const PROJECTION_GENERATOR_VERSION = '1';
14 export const ACTIVE_HARNESSES = new Set(['cursor_rule', 'cli_runbook']);
15 export const RESERVED_HARNESSES = new Set(['cursor_skill', 'mcp_prompt']);
16 export const INERT_HARNESSES = new Set(['agent_bundle']);
17 export const MAX_RENDERED_BYTES = 65536;
18 export const GENERATED_MARKER_PREFIX = 'GENERATED FROM CANONICAL FLOW';
19
20 /** @typedef {'cursor_rule'|'cursor_skill'|'mcp_prompt'|'cli_runbook'|'agent_bundle'} Harness */
21
22 export const HARNESS_VALUES = /** @type {const} */ ([
23 'cursor_rule',
24 'cursor_skill',
25 'mcp_prompt',
26 'cli_runbook',
27 'agent_bundle',
28 ]);
29
30 /** @type {readonly string[]} */
31 const FLOW_PROJECTION_CLIENT_KEYS = [
32 'schema',
33 'flow_id',
34 'flow_version',
35 'harness',
36 'rendered',
37 'generated_from_canonical',
38 'editable',
39 'fidelity',
40 ];
41
42 /**
43 * @param {string} harness
44 * @param {{ agentBundleEnabled?: boolean }} [options]
45 * @returns {boolean}
46 */
47 export function isHarnessActive(harness, options = {}) {
48 if (ACTIVE_HARNESSES.has(harness)) return true;
49 if (harness === 'agent_bundle' && options.agentBundleEnabled === true) return true;
50 return false;
51 }
52
53 /**
54 * @param {boolean} gateOn
55 * @returns {boolean}
56 */
57 export function isAgentBundleInert(gateOn) {
58 return !gateOn;
59 }
60
61 /**
62 * @param {string} flowId
63 * @param {string} flowVersion
64 * @returns {string}
65 */
66 export function buildGeneratedMarker(flowId, flowVersion) {
67 return `<!-- ${GENERATED_MARKER_PREFIX} ${flowId}@${flowVersion} (generator v${PROJECTION_GENERATOR_VERSION}) — DO NOT EDIT; regenerate via knowtation flow project -->`;
68 }
69
70 /**
71 * @param {string} text
72 * @returns {string}
73 */
74 function escapeMarkdownData(text) {
75 if (typeof text !== 'string') return '';
76 return text.replace(/\r\n/g, '\n').replace(/</g, '&lt;').replace(/>/g, '&gt;');
77 }
78
79 /**
80 * @param {string} line
81 * @returns {string}
82 */
83 function yamlQuote(line) {
84 const s = String(line).replace(/\r\n/g, '\n');
85 if (/^[\w .,:/-]+$/.test(s) && !s.includes(':')) return s;
86 return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
87 }
88
89 /**
90 * @param {{ kind: string, id: string }[]} refs
91 * @returns {string}
92 */
93 function formatHandleRefs(refs) {
94 if (!Array.isArray(refs) || refs.length === 0) return '';
95 return refs.map((r) => `${r.kind}:${r.id}`).join(', ');
96 }
97
98 /**
99 * @param {object} step
100 * @returns {string}
101 */
102 function formatVerification(step) {
103 const v = step.verification;
104 if (!v || typeof v !== 'object') return '';
105 const evidence = v.evidence_required === true ? 'yes' : 'no';
106 return `${v.kind} — ${v.description} (evidence required: ${evidence})`;
107 }
108
109 /**
110 * @param {object} step
111 * @returns {string[]}
112 */
113 function formatBoundaries(step) {
114 if (!Array.isArray(step.boundaries)) return [];
115 return step.boundaries.map((b) => `- ${escapeMarkdownData(b)}`);
116 }
117
118 /**
119 * @param {object} step
120 * @returns {string}
121 */
122 function renderCursorRuleStep(step) {
123 const lines = [
124 `## Step ${step.ordinal}: ${escapeMarkdownData(step.owned_job)}`,
125 '',
126 `**Instruction:** ${escapeMarkdownData(step.instruction)}`,
127 '',
128 `**Trigger:** ${escapeMarkdownData(step.trigger)}`,
129 '',
130 '**Boundaries:**',
131 ...formatBoundaries(step),
132 '',
133 `**Output shape:** ${escapeMarkdownData(step.output_shape)}`,
134 '',
135 `**Verification:** ${escapeMarkdownData(formatVerification(step))}`,
136 ];
137 const skillRefs = formatHandleRefs(step.skill_refs);
138 if (skillRefs) {
139 lines.push('', `**Skill refs:** ${escapeMarkdownData(skillRefs)}`);
140 }
141 return lines.join('\n');
142 }
143
144 /**
145 * @param {object} step
146 * @returns {string}
147 */
148 /**
149 * @param {object} step
150 * @returns {object}
151 */
152 function renderAgentBundleStep(step) {
153 const skillRefs = Array.isArray(step.skill_refs)
154 ? step.skill_refs
155 .filter((r) => r && typeof r === 'object')
156 .map((r) => ({
157 kind: r.kind,
158 id: typeof r.id === 'string' ? r.id : '',
159 }))
160 .filter((r) => r.id.length > 0)
161 : [];
162 return {
163 step_id: step.step_id,
164 ordinal: step.ordinal,
165 owned_job: typeof step.owned_job === 'string' ? step.owned_job : '',
166 instruction: typeof step.instruction === 'string' ? step.instruction : '',
167 trigger: typeof step.trigger === 'string' ? step.trigger : '',
168 when_not_to_run: typeof step.when_not_to_run === 'string' ? step.when_not_to_run : '',
169 boundaries: Array.isArray(step.boundaries) ? step.boundaries.map(String) : [],
170 output_shape: typeof step.output_shape === 'string' ? step.output_shape : '',
171 verification:
172 step.verification && typeof step.verification === 'object'
173 ? {
174 kind: step.verification.kind,
175 evidence_required: step.verification.evidence_required === true,
176 description:
177 typeof step.verification.description === 'string' ? step.verification.description : '',
178 }
179 : { kind: 'human_review', evidence_required: true, description: '' },
180 skill_refs: skillRefs,
181 };
182 }
183
184 /**
185 * @param {object} flow
186 * @param {object[]} steps
187 * @param {string[]} allowedTools
188 * @returns {string}
189 */
190 export function renderAgentBundle(flow, steps, allowedTools = []) {
191 const ordered = [...steps].sort((a, b) => a.ordinal - b.ordinal);
192 const marker = `GENERATED FROM CANONICAL FLOW ${flow.flow_id}@${flow.version} — DO NOT EDIT`;
193 const bundle = {
194 schema: 'knowtation.agent_bundle/v0',
195 flow_id: flow.flow_id,
196 flow_version: flow.version,
197 title: flow.title ?? '',
198 summary: flow.summary ?? '',
199 scope: flow.scope,
200 generated_marker: marker,
201 grant_required: true,
202 allowed_tools: [...allowedTools].sort(),
203 steps: ordered.map(renderAgentBundleStep),
204 fidelity: { dropped_fields: [], notes: null },
205 };
206 return JSON.stringify(bundle);
207 }
208
209 function renderCliRunbookStep(step) {
210 const lines = [
211 `## Step ${step.ordinal}`,
212 '',
213 `- **Owned job:** ${escapeMarkdownData(step.owned_job)}`,
214 `- **Instruction:** ${escapeMarkdownData(step.instruction)}`,
215 `- **Trigger:** ${escapeMarkdownData(step.trigger)}`,
216 ];
217 if (typeof step.when_not_to_run === 'string' && step.when_not_to_run.trim()) {
218 lines.push(`- **When not to run:** ${escapeMarkdownData(step.when_not_to_run)}`);
219 }
220 const requires = formatHandleRefs(step.requires);
221 if (requires) {
222 lines.push(`- **Requires:** ${escapeMarkdownData(requires)}`);
223 }
224 if (Array.isArray(step.boundaries) && step.boundaries.length > 0) {
225 lines.push('- **Boundaries:**');
226 for (const b of step.boundaries) {
227 lines.push(` - ${escapeMarkdownData(b)}`);
228 }
229 }
230 const skillRefs = formatHandleRefs(step.skill_refs);
231 if (skillRefs) {
232 lines.push(`- **Skill refs:** ${escapeMarkdownData(skillRefs)}`);
233 }
234 if (Array.isArray(step.inputs) && step.inputs.length > 0) {
235 const inputs = step.inputs.map((i) => `${i.name} (from ${i.from})`).join(', ');
236 lines.push(`- **Inputs:** ${escapeMarkdownData(inputs)}`);
237 }
238 if (Array.isArray(step.outputs) && step.outputs.length > 0) {
239 const outputs = step.outputs.map((o) => `${o.name}:${o.type}`).join(', ');
240 lines.push(`- **Outputs:** ${escapeMarkdownData(outputs)}`);
241 }
242 lines.push(
243 `- **Output shape:** ${escapeMarkdownData(step.output_shape)}`,
244 `- **Verification:** ${escapeMarkdownData(formatVerification(step))}`,
245 );
246 return lines.join('\n');
247 }
248
249 /**
250 * @param {object} flow
251 * @param {object[]} steps
252 * @param {Harness} harness
253 * @param {string} marker
254 * @returns {{ rendered: string, truncated: boolean }}
255 */
256 function buildRendered(flow, steps, harness, marker) {
257 const ordered = [...steps].sort((a, b) => a.ordinal - b.ordinal);
258 const parts = [];
259
260 if (harness === 'cursor_rule') {
261 const tags = Array.isArray(flow.tags) ? flow.tags : [];
262 const globs = tags.length > 0 ? tags.map((t) => `**/*${t}*`).join(', ') : '**/*';
263 parts.push(
264 '---',
265 `description: ${yamlQuote(flow.summary ?? flow.title ?? '')}`,
266 `globs: ${yamlQuote(globs)}`,
267 'alwaysApply: false',
268 '---',
269 '',
270 marker,
271 '',
272 );
273 for (const step of ordered) {
274 parts.push(renderCursorRuleStep(step), '');
275 }
276 } else if (harness === 'cli_runbook') {
277 parts.push(
278 `# ${escapeMarkdownData(flow.title ?? flow.flow_id)}`,
279 '',
280 marker,
281 '',
282 escapeMarkdownData(flow.summary ?? ''),
283 '',
284 );
285 for (const step of ordered) {
286 parts.push(renderCliRunbookStep(step), '');
287 }
288 } else if (harness === 'agent_bundle') {
289 return { rendered: renderAgentBundle(flow, ordered, []), truncated: false };
290 }
291
292 let rendered = parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
293 if (!rendered.endsWith('\n')) rendered += '\n';
294
295 let truncated = false;
296 if (Buffer.byteLength(rendered, 'utf8') > MAX_RENDERED_BYTES) {
297 truncated = true;
298 const header =
299 harness === 'cursor_rule'
300 ? [
301 '---',
302 `description: ${yamlQuote(flow.summary ?? flow.title ?? '')}`,
303 `globs: ${yamlQuote('**/*')}`,
304 'alwaysApply: false',
305 '---',
306 '',
307 marker,
308 '',
309 ].join('\n')
310 : [
311 `# ${escapeMarkdownData(flow.title ?? flow.flow_id)}`,
312 '',
313 marker,
314 '',
315 escapeMarkdownData(flow.summary ?? ''),
316 '',
317 ].join('\n');
318 const chunks = [];
319 let byteLen = Buffer.byteLength(`${header}\n`, 'utf8');
320 for (const step of ordered) {
321 const stepText =
322 harness === 'cursor_rule'
323 ? `${renderCursorRuleStep(step)}\n\n`
324 : `${renderCliRunbookStep(step)}\n\n`;
325 const stepBytes = Buffer.byteLength(stepText, 'utf8');
326 if (byteLen + stepBytes > MAX_RENDERED_BYTES) {
327 break;
328 }
329 chunks.push(stepText);
330 byteLen += stepBytes;
331 }
332 rendered = `${header}${chunks.join('')}`.trimEnd();
333 if (!rendered.endsWith('\n')) rendered += '\n';
334 }
335
336 return { rendered, truncated };
337 }
338
339 /**
340 * @param {Harness} harness
341 * @param {object} flow
342 * @param {object[]} steps
343 * @returns {{ dropped_fields: string[], notes?: string }}
344 */
345 export function computeFidelity(harness, flow, steps) {
346 if (harness === 'agent_bundle') {
347 return { dropped_fields: [], notes: null };
348 }
349
350 void flow;
351 const dropped = new Set();
352
353 for (const step of steps) {
354 if (harness === 'cursor_rule') {
355 if (typeof step.when_not_to_run === 'string' && step.when_not_to_run.trim()) {
356 dropped.add('when_not_to_run');
357 }
358 if (Array.isArray(step.requires) && step.requires.length > 0) {
359 dropped.add('requires');
360 }
361 if (Array.isArray(step.inputs) && step.inputs.length > 0) {
362 dropped.add('inputs');
363 }
364 if (Array.isArray(step.outputs) && step.outputs.length > 0) {
365 dropped.add('outputs');
366 }
367 }
368 if (step.verification?.evidence_required === true) {
369 void 0;
370 }
371 }
372
373 /** @type {{ dropped_fields: string[], notes?: string }} */
374 const result = {
375 dropped_fields: [...dropped].sort(),
376 };
377
378 if (harness === 'cursor_rule' && result.dropped_fields.includes('when_not_to_run')) {
379 result.notes = 'cursor_rule has no anti-trigger slot';
380 }
381
382 return result;
383 }
384
385 /**
386 * @param {object} flow
387 * @param {object[]} steps
388 * @param {{ harness: Harness, generatedAt?: string, truncatedNote?: boolean, allowedTools?: string[] }} options
389 * @returns {object}
390 */
391 export function projectFlow(flow, steps, options) {
392 const harness = options.harness;
393 const marker = buildGeneratedMarker(flow.flow_id, flow.version);
394 let rendered;
395 let truncated;
396 if (harness === 'agent_bundle') {
397 const allowed = Array.isArray(options.allowedTools) ? options.allowedTools : [];
398 rendered = renderAgentBundle(flow, steps, allowed);
399 truncated = Buffer.byteLength(rendered, 'utf8') > MAX_RENDERED_BYTES;
400 if (truncated) {
401 rendered = rendered.slice(0, MAX_RENDERED_BYTES);
402 }
403 } else {
404 const built = buildRendered(flow, steps, harness, marker);
405 rendered = built.rendered;
406 truncated = built.truncated;
407 }
408 const fidelity = computeFidelity(harness, flow, steps);
409 if (truncated) {
410 fidelity.notes = fidelity.notes
411 ? `${fidelity.notes}; rendered truncated at ${MAX_RENDERED_BYTES} bytes`
412 : `rendered truncated at ${MAX_RENDERED_BYTES} bytes`;
413 }
414
415 return {
416 schema: 'knowtation.flow_projection/v0',
417 flow_id: flow.flow_id,
418 flow_version: flow.version,
419 harness,
420 rendered,
421 generated_from_canonical: true,
422 editable: false,
423 fidelity,
424 };
425 }
426
427 /**
428 * @param {string} rendered
429 * @returns {string}
430 */
431 export function renderedContentHash(rendered) {
432 const digest = createHash('sha256').update(rendered, 'utf8').digest('hex');
433 return `sha256:${digest}`;
434 }
435
436 /**
437 * @param {string} projectionFlowVersion
438 * @param {string} latestFlowVersion
439 * @returns {boolean}
440 */
441 export function isProjectionStale(projectionFlowVersion, latestFlowVersion) {
442 const a = parseSemver(projectionFlowVersion);
443 const b = parseSemver(latestFlowVersion);
444 if (!a || !b) return true;
445 return compareSemver(a, b) < 0;
446 }
447
448 /**
449 * @param {string} text
450 * @returns {string}
451 */
452 function normalizeMarkerForCompare(text) {
453 return text.replace(
454 /<!--\s*GENERATED FROM CANONICAL FLOW[\s\S]*?-->/g,
455 '<!-- GENERATED_MARKER -->',
456 );
457 }
458
459 /**
460 * @param {string|null|undefined} text
461 * @returns {boolean}
462 */
463 function hasGeneratedMarker(text) {
464 return typeof text === 'string' && text.includes(GENERATED_MARKER_PREFIX);
465 }
466
467 /**
468 * @param {string|null|undefined} onDiskRendered
469 * @param {string} freshRendered
470 * @returns {{ drift: boolean, reason: 'clean'|'edited'|'missing_marker'|'absent' }}
471 */
472 export function detectDrift(onDiskRendered, freshRendered) {
473 if (onDiskRendered === null || onDiskRendered === undefined || onDiskRendered === '') {
474 return { drift: true, reason: 'absent' };
475 }
476 if (!hasGeneratedMarker(onDiskRendered)) {
477 return { drift: true, reason: 'missing_marker' };
478 }
479 const diskNorm = normalizeMarkerForCompare(onDiskRendered);
480 const freshNorm = normalizeMarkerForCompare(freshRendered);
481 if (diskNorm === freshNorm) {
482 return { drift: false, reason: 'clean' };
483 }
484 return { drift: true, reason: 'edited' };
485 }
486
487 /**
488 * @param {object} projection
489 * @returns {object}
490 */
491 export function flowProjectionForClient(projection) {
492 /** @type {Record<string, unknown>} */
493 const out = {};
494 for (const key of FLOW_PROJECTION_CLIENT_KEYS) {
495 if (key in projection) {
496 out[key] = projection[key];
497 }
498 }
499 return out;
500 }
501
502 /**
503 * Default local derived-artifact path for CLI --out/--check.
504 *
505 * @param {string} flowId
506 * @param {Harness} harness
507 * @returns {string|null}
508 */
509 export function defaultProjectionOutPath(flowId, harness) {
510 const slug = flowId.replace(/^flow_/, '').replace(/_/g, '-');
511 if (harness === 'cursor_rule') return `.cursor/rules/${slug}.mdc`;
512 if (harness === 'cli_runbook') return 'AGENTS.md';
513 return null;
514 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 14 hours ago