| 1 | export type StructuredCheckpoint = {
|
| 2 | state: 'active' | 'blocked' | 'ready_to_finalize' | 'done' | 'cancelled';
|
| 3 | progress: number;
|
| 4 | criteriaMet: string[];
|
| 5 | criteriaRemaining: string[];
|
| 6 | blockerCodes: string[];
|
| 7 | topicStatus: 'in_scope' | 'drifting' | 'changed';
|
| 8 | expectedRemainingTurns?: number;
|
| 9 | needsHuman: boolean;
|
| 10 | confidence: number;
|
| 11 | };
|
| 12 |
|
| 13 | const strings = (value: unknown, limit: number, maxLength: number) =>
|
| 14 | (Array.isArray(value) ? value : [])
|
| 15 | .filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
| 16 | .slice(0, limit)
|
| 17 | .map((item) => item.trim().slice(0, maxLength));
|
| 18 |
|
| 19 | const checkpointJson = (value: string) => {
|
| 20 | const marker = value.indexOf('OPENCLASP_CHECKPOINT');
|
| 21 | const source = marker >= 0 ? value.slice(marker + 'OPENCLASP_CHECKPOINT'.length) : value;
|
| 22 | const start = source.indexOf('{');
|
| 23 | if (start < 0) throw new Error('Checkpoint JSON object is missing');
|
| 24 | let depth = 0;
|
| 25 | let quoted = false;
|
| 26 | let escaped = false;
|
| 27 | for (let index = start; index < source.length; index += 1) {
|
| 28 | const character = source[index]!;
|
| 29 | if (quoted) {
|
| 30 | if (escaped) escaped = false;
|
| 31 | else if (character === '\\') escaped = true;
|
| 32 | else if (character === '"') quoted = false;
|
| 33 | continue;
|
| 34 | }
|
| 35 | if (character === '"') quoted = true;
|
| 36 | else if (character === '{') depth += 1;
|
| 37 | else if (character === '}' && --depth === 0) return JSON.parse(source.slice(start, index + 1));
|
| 38 | }
|
| 39 | throw new Error('Checkpoint JSON object is incomplete');
|
| 40 | };
|
| 41 |
|
| 42 | export function parseCheckpoint(text: string): StructuredCheckpoint {
|
| 43 | const value = checkpointJson(text) as Record<string, unknown>;
|
| 44 | const states = ['active', 'blocked', 'ready_to_finalize', 'done', 'cancelled'] as const;
|
| 45 | const topics = ['in_scope', 'drifting', 'changed'] as const;
|
| 46 | if (!states.includes(value.state as (typeof states)[number]))
|
| 47 | throw new Error('Checkpoint state is invalid');
|
| 48 | if (!topics.includes(value.topicStatus as (typeof topics)[number]))
|
| 49 | throw new Error('Checkpoint topic status is invalid');
|
| 50 | if (typeof value.progress !== 'number' || value.progress < 0 || value.progress > 1)
|
| 51 | throw new Error('Checkpoint progress is invalid');
|
| 52 | if (typeof value.confidence !== 'number' || value.confidence < 0 || value.confidence > 1)
|
| 53 | throw new Error('Checkpoint confidence is invalid');
|
| 54 | if (typeof value.needsHuman !== 'boolean') throw new Error('Checkpoint needsHuman is invalid');
|
| 55 | if (
|
| 56 | value.expectedRemainingTurns !== undefined &&
|
| 57 | (typeof value.expectedRemainingTurns !== 'number' ||
|
| 58 | !Number.isInteger(value.expectedRemainingTurns) ||
|
| 59 | value.expectedRemainingTurns < 0 ||
|
| 60 | value.expectedRemainingTurns > 1000)
|
| 61 | )
|
| 62 | throw new Error('Checkpoint expectedRemainingTurns is invalid');
|
| 63 | return {
|
| 64 | state: value.state as StructuredCheckpoint['state'],
|
| 65 | progress: value.progress,
|
| 66 | criteriaMet: strings(value.criteriaMet, 100, 1000),
|
| 67 | criteriaRemaining: strings(value.criteriaRemaining, 100, 1000),
|
| 68 | blockerCodes: strings(value.blockerCodes, 32, 64).filter((item) =>
|
| 69 | /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,63}$/.test(item),
|
| 70 | ),
|
| 71 | topicStatus: value.topicStatus as StructuredCheckpoint['topicStatus'],
|
| 72 | ...(value.expectedRemainingTurns !== undefined
|
| 73 | ? { expectedRemainingTurns: value.expectedRemainingTurns as number }
|
| 74 | : {}),
|
| 75 | needsHuman: value.needsHuman,
|
| 76 | confidence: value.confidence,
|
| 77 | };
|
| 78 | }
|