@open-agent-toolkit/cli 0.2.20 → 0.2.22
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.
- package/assets/docs/workflows/projects/artifacts.md +1 -1
- package/assets/docs/workflows/skills/explainer-kit.md +188 -23
- package/assets/docs/workflows/skills/index.md +1 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +87 -25
- package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
- package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
- package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
- package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
- package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
- package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
- package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
- package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
- package/assets/skills/explainer-kit/references/contracts.md +45 -19
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
- package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
- package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +687 -0
- package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +313 -14
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +314 -41
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +152 -2
- package/assets/skills/explainer-kit/scripts/run.mjs +789 -272
- package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
- package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
- package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
- package/assets/skills/explainer-kit/templates/house-style.html +82 -0
- package/assets/skills/oat-brainstorm/SKILL.md +1 -1
- package/assets/skills/oat-brainstorm/scripts/helper.js +18 -11
- package/assets/skills/oat-brainstorm/scripts/server.cjs +109 -55
- package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
- package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
- package/assets/skills/oat-project-complete/SKILL.md +18 -2
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
- package/assets/skills/oat-wave-execute/SKILL.md +12 -19
- package/assets/skills/oat-wave-program/SKILL.md +12 -13
- package/dist/commands/init/tools/index.d.ts.map +1 -1
- package/dist/commands/init/tools/index.js +4 -4
- package/dist/engine/compute-plan.js +3 -3
- package/package.json +2 -2
- package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
- package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
|
@@ -2,9 +2,21 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { writeJsonAtomic } from './fs-safe.mjs';
|
|
5
|
+
import { loadRecipe, recipeFloor } from './recipes.mjs';
|
|
6
|
+
import { validateSafeRelativePath } from './safe-paths.mjs';
|
|
5
7
|
|
|
6
8
|
const APPROVAL_PATH = 'source/content-approval.json';
|
|
9
|
+
const APPROVAL_SCHEMA_V1 = 'explainer-kit.content-approval/v1';
|
|
10
|
+
const APPROVAL_SCHEMA_V2 = 'explainer-kit.content-approval/v2';
|
|
7
11
|
const MODES = new Set(['interactive', 'unattended']);
|
|
12
|
+
const STATUSES = new Set(['pending', 'approved', 'rejected']);
|
|
13
|
+
const ORIGINS = new Set(['floor', 'expansion']);
|
|
14
|
+
const AUTHORING_TYPES = new Set(['markdown', 'html']);
|
|
15
|
+
const CONTENT_EXTENSIONS = new Map([
|
|
16
|
+
['markdown', 'md'],
|
|
17
|
+
['html', 'html'],
|
|
18
|
+
]);
|
|
19
|
+
const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
8
20
|
const DECISIONS = new Map([
|
|
9
21
|
['approve', 'approved'],
|
|
10
22
|
['approved', 'approved'],
|
|
@@ -17,13 +29,20 @@ export async function resolveContentApproval(
|
|
|
17
29
|
mode,
|
|
18
30
|
reviewedSource,
|
|
19
31
|
authorResultPaths,
|
|
32
|
+
artifacts,
|
|
20
33
|
) {
|
|
21
34
|
assertRun(run);
|
|
22
35
|
if (!MODES.has(mode)) {
|
|
23
36
|
throw new Error('Content approval mode must be interactive or unattended.');
|
|
24
37
|
}
|
|
38
|
+
if (artifacts !== undefined) {
|
|
39
|
+
assertArtifacts(artifacts);
|
|
40
|
+
}
|
|
41
|
+
if (authorResultPaths !== undefined) {
|
|
42
|
+
assertAuthorResultPaths(authorResultPaths);
|
|
43
|
+
}
|
|
25
44
|
|
|
26
|
-
const previous = await
|
|
45
|
+
const previous = await readContentApproval(run);
|
|
27
46
|
let record;
|
|
28
47
|
if (mode === 'unattended') {
|
|
29
48
|
assertAuthorResultPaths(authorResultPaths);
|
|
@@ -31,31 +50,41 @@ export async function resolveContentApproval(
|
|
|
31
50
|
reviewedSource ?? approvedSourceProvenance(run.request.factBase);
|
|
32
51
|
assertProvenance(provenance);
|
|
33
52
|
record = {
|
|
34
|
-
schemaVersion:
|
|
53
|
+
schemaVersion: APPROVAL_SCHEMA_V2,
|
|
35
54
|
runId: run.runId,
|
|
36
55
|
mode,
|
|
37
56
|
status: 'approved',
|
|
57
|
+
marking: 'auto-drafted',
|
|
38
58
|
reviewedSource: structuredClone(provenance),
|
|
39
59
|
authorResultPaths: [...authorResultPaths],
|
|
40
60
|
attempts: previous?.attempts ?? [],
|
|
61
|
+
...optionalArtifacts(artifacts ?? previous?.artifacts),
|
|
41
62
|
};
|
|
42
63
|
} else if (reviewedSource === undefined) {
|
|
43
64
|
record = previous ?? {
|
|
44
|
-
schemaVersion:
|
|
65
|
+
schemaVersion: APPROVAL_SCHEMA_V2,
|
|
45
66
|
runId: run.runId,
|
|
46
67
|
mode,
|
|
47
68
|
status: 'pending',
|
|
48
69
|
attempts: [],
|
|
49
70
|
};
|
|
71
|
+
record = {
|
|
72
|
+
...record,
|
|
73
|
+
...optionalAuthorResultPaths(
|
|
74
|
+
authorResultPaths ?? previous?.authorResultPaths,
|
|
75
|
+
),
|
|
76
|
+
...optionalArtifacts(artifacts ?? previous?.artifacts),
|
|
77
|
+
};
|
|
50
78
|
} else {
|
|
51
79
|
const decision = normalizeDecision(reviewedSource.decision);
|
|
52
80
|
const attempt = interactiveAttempt(reviewedSource, decision);
|
|
53
81
|
record = {
|
|
54
|
-
schemaVersion:
|
|
82
|
+
schemaVersion: APPROVAL_SCHEMA_V2,
|
|
55
83
|
runId: run.runId,
|
|
56
84
|
mode,
|
|
57
85
|
status: decision,
|
|
58
86
|
attempts: [...(previous?.attempts ?? []), attempt],
|
|
87
|
+
...(decision === 'approved' && { marking: 'human-approved' }),
|
|
59
88
|
...(decision === 'approved' && {
|
|
60
89
|
reviewedSource: structuredClone(
|
|
61
90
|
reviewedSource.source ?? {
|
|
@@ -64,6 +93,10 @@ export async function resolveContentApproval(
|
|
|
64
93
|
},
|
|
65
94
|
),
|
|
66
95
|
}),
|
|
96
|
+
...optionalAuthorResultPaths(
|
|
97
|
+
authorResultPaths ?? previous?.authorResultPaths,
|
|
98
|
+
),
|
|
99
|
+
...optionalArtifacts(artifacts ?? previous?.artifacts),
|
|
67
100
|
};
|
|
68
101
|
if (record.reviewedSource) {
|
|
69
102
|
assertProvenance(record.reviewedSource);
|
|
@@ -79,24 +112,204 @@ export async function resolveContentApproval(
|
|
|
79
112
|
};
|
|
80
113
|
}
|
|
81
114
|
|
|
82
|
-
async function
|
|
115
|
+
export async function readContentApproval(run) {
|
|
116
|
+
assertRun(run);
|
|
83
117
|
try {
|
|
84
118
|
const previous = JSON.parse(
|
|
85
119
|
await readFile(join(run.runRoot, APPROVAL_PATH), 'utf8'),
|
|
86
120
|
);
|
|
87
|
-
if (
|
|
88
|
-
previous.schemaVersion !== 'explainer-kit.content-approval/v1' ||
|
|
89
|
-
previous.runId !== run.runId
|
|
90
|
-
) {
|
|
121
|
+
if (previous.runId !== run.runId) {
|
|
91
122
|
throw new Error('Content approval record does not belong to this run.');
|
|
92
123
|
}
|
|
93
|
-
|
|
124
|
+
if (previous.schemaVersion === APPROVAL_SCHEMA_V1) {
|
|
125
|
+
return normalizeLegacyRecord(run, previous);
|
|
126
|
+
}
|
|
127
|
+
if (previous.schemaVersion !== APPROVAL_SCHEMA_V2) {
|
|
128
|
+
throw new Error('Content approval record has an unsupported version.');
|
|
129
|
+
}
|
|
130
|
+
assertApprovalRecord(previous);
|
|
131
|
+
return structuredClone(previous);
|
|
94
132
|
} catch (error) {
|
|
95
133
|
if (error?.code === 'ENOENT') return null;
|
|
96
134
|
throw error;
|
|
97
135
|
}
|
|
98
136
|
}
|
|
99
137
|
|
|
138
|
+
function normalizeLegacyRecord(run, previous) {
|
|
139
|
+
assertLegacyRecord(previous);
|
|
140
|
+
const marking =
|
|
141
|
+
previous.status === 'approved'
|
|
142
|
+
? previous.mode === 'unattended'
|
|
143
|
+
? 'auto-drafted'
|
|
144
|
+
: 'human-approved'
|
|
145
|
+
: undefined;
|
|
146
|
+
const record = {
|
|
147
|
+
schemaVersion: APPROVAL_SCHEMA_V2,
|
|
148
|
+
runId: previous.runId,
|
|
149
|
+
mode: previous.mode,
|
|
150
|
+
status: previous.status,
|
|
151
|
+
...(marking && { marking }),
|
|
152
|
+
...(previous.reviewedSource && {
|
|
153
|
+
reviewedSource: structuredClone(previous.reviewedSource),
|
|
154
|
+
}),
|
|
155
|
+
...(previous.authorResultPaths && {
|
|
156
|
+
authorResultPaths: [...previous.authorResultPaths],
|
|
157
|
+
}),
|
|
158
|
+
attempts: structuredClone(previous.attempts),
|
|
159
|
+
artifacts: legacyFloorArtifacts(run, previous.authorResultPaths),
|
|
160
|
+
};
|
|
161
|
+
assertApprovalRecord(record);
|
|
162
|
+
return record;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function legacyFloorArtifacts(run, authorResultPaths = []) {
|
|
166
|
+
const recipe = loadRecipe(run.request.recipe.id, run.request.recipe.version);
|
|
167
|
+
const resultPaths = new Set(authorResultPaths);
|
|
168
|
+
return recipeFloor(recipe).map((artifact) => {
|
|
169
|
+
const authorResultPath = `source/author/${artifact.id}.json`;
|
|
170
|
+
const authoring = artifact.authoring ?? 'markdown';
|
|
171
|
+
return {
|
|
172
|
+
artifactId: artifact.id,
|
|
173
|
+
origin: 'floor',
|
|
174
|
+
authoring,
|
|
175
|
+
contentPath: contentPathFor(artifact.id, authoring),
|
|
176
|
+
...(resultPaths.has(authorResultPath) && { authorResultPath }),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function contentPathFor(artifactId, authoring) {
|
|
182
|
+
const extension = CONTENT_EXTENSIONS.get(authoring);
|
|
183
|
+
if (!extension) {
|
|
184
|
+
throw new Error(`Unsupported content authoring mode: ${authoring}`);
|
|
185
|
+
}
|
|
186
|
+
return `source/content/${artifactId}.${extension}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function optionalArtifacts(artifacts) {
|
|
190
|
+
return artifacts === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { artifacts: structuredClone(artifacts) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function optionalAuthorResultPaths(paths) {
|
|
196
|
+
return paths === undefined
|
|
197
|
+
? {}
|
|
198
|
+
: { authorResultPaths: structuredClone(paths) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assertLegacyRecord(record) {
|
|
202
|
+
if (
|
|
203
|
+
!isObject(record) ||
|
|
204
|
+
record.schemaVersion !== APPROVAL_SCHEMA_V1 ||
|
|
205
|
+
typeof record.runId !== 'string' ||
|
|
206
|
+
!MODES.has(record.mode) ||
|
|
207
|
+
!STATUSES.has(record.status) ||
|
|
208
|
+
!Array.isArray(record.attempts)
|
|
209
|
+
) {
|
|
210
|
+
throw new Error('Legacy content approval record is malformed.');
|
|
211
|
+
}
|
|
212
|
+
if (record.reviewedSource !== undefined) {
|
|
213
|
+
assertProvenance(record.reviewedSource);
|
|
214
|
+
}
|
|
215
|
+
if (record.authorResultPaths !== undefined) {
|
|
216
|
+
assertAuthorResultPaths(record.authorResultPaths);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function assertApprovalRecord(record) {
|
|
221
|
+
if (
|
|
222
|
+
!isObject(record) ||
|
|
223
|
+
record.schemaVersion !== APPROVAL_SCHEMA_V2 ||
|
|
224
|
+
typeof record.runId !== 'string' ||
|
|
225
|
+
!MODES.has(record.mode) ||
|
|
226
|
+
!STATUSES.has(record.status) ||
|
|
227
|
+
!Array.isArray(record.attempts)
|
|
228
|
+
) {
|
|
229
|
+
throw new Error('Content approval v2 record is malformed.');
|
|
230
|
+
}
|
|
231
|
+
const expectedMarking =
|
|
232
|
+
record.status !== 'approved'
|
|
233
|
+
? undefined
|
|
234
|
+
: record.mode === 'unattended'
|
|
235
|
+
? 'auto-drafted'
|
|
236
|
+
: 'human-approved';
|
|
237
|
+
if (record.marking !== expectedMarking) {
|
|
238
|
+
throw new Error('Content approval marking does not match mode and status.');
|
|
239
|
+
}
|
|
240
|
+
if (record.reviewedSource !== undefined) {
|
|
241
|
+
assertProvenance(record.reviewedSource);
|
|
242
|
+
}
|
|
243
|
+
if (record.authorResultPaths !== undefined) {
|
|
244
|
+
assertAuthorResultPaths(record.authorResultPaths);
|
|
245
|
+
}
|
|
246
|
+
if (record.artifacts !== undefined) {
|
|
247
|
+
assertArtifacts(record.artifacts);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function assertArtifacts(value) {
|
|
252
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
253
|
+
throw new Error('Content approval artifacts must be a non-empty array.');
|
|
254
|
+
}
|
|
255
|
+
const artifactIds = new Set();
|
|
256
|
+
const contentPaths = new Set();
|
|
257
|
+
const authorResultPaths = new Set();
|
|
258
|
+
for (const artifact of value) {
|
|
259
|
+
const keys = Object.keys(artifact ?? {}).sort();
|
|
260
|
+
const expectedKeys = [
|
|
261
|
+
'artifactId',
|
|
262
|
+
'authoring',
|
|
263
|
+
'contentPath',
|
|
264
|
+
'origin',
|
|
265
|
+
...(artifact?.profileId === undefined ? [] : ['profileId']),
|
|
266
|
+
...(artifact?.authorResultPath === undefined ? [] : ['authorResultPath']),
|
|
267
|
+
].sort();
|
|
268
|
+
if (
|
|
269
|
+
!isObject(artifact) ||
|
|
270
|
+
keys.length !== expectedKeys.length ||
|
|
271
|
+
keys.some((key, index) => key !== expectedKeys[index]) ||
|
|
272
|
+
!SAFE_ID.test(artifact.artifactId) ||
|
|
273
|
+
!ORIGINS.has(artifact.origin) ||
|
|
274
|
+
!AUTHORING_TYPES.has(artifact.authoring) ||
|
|
275
|
+
!validateSafeRelativePath(artifact.contentPath).valid
|
|
276
|
+
) {
|
|
277
|
+
throw new Error('Content approval artifact entry is malformed.');
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
(artifact.origin === 'expansion') !==
|
|
281
|
+
(typeof artifact.profileId === 'string' &&
|
|
282
|
+
SAFE_ID.test(artifact.profileId))
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'Expansion approval artifacts require a safe profileId, and floor artifacts forbid one.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
artifact.authorResultPath !== undefined &&
|
|
290
|
+
(!validateSafeRelativePath(artifact.authorResultPath).valid ||
|
|
291
|
+
!/^source\/author\/[^/]+\.json$/.test(artifact.authorResultPath))
|
|
292
|
+
) {
|
|
293
|
+
throw new Error('Content approval authorResultPath is malformed.');
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
artifactIds.has(artifact.artifactId) ||
|
|
297
|
+
contentPaths.has(artifact.contentPath) ||
|
|
298
|
+
(artifact.authorResultPath !== undefined &&
|
|
299
|
+
authorResultPaths.has(artifact.authorResultPath))
|
|
300
|
+
) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
'Content approval artifact paths and ids must be unique.',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
artifactIds.add(artifact.artifactId);
|
|
306
|
+
contentPaths.add(artifact.contentPath);
|
|
307
|
+
if (artifact.authorResultPath !== undefined) {
|
|
308
|
+
authorResultPaths.add(artifact.authorResultPath);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
100
313
|
function interactiveAttempt(source, status) {
|
|
101
314
|
if (!isObject(source)) {
|
|
102
315
|
throw new TypeError('Interactive approval requires a review decision.');
|
|
@@ -12,8 +12,12 @@ const SCHEMA_FILES = {
|
|
|
12
12
|
'durability-evidence': 'durability-evidence.schema.json',
|
|
13
13
|
'publish-request': 'publish-request.schema.json',
|
|
14
14
|
'publish-receipt': 'publish-receipt.schema.json',
|
|
15
|
-
'author-request': 'author-request.schema.json',
|
|
16
|
-
'author-result': 'author-result.schema.json',
|
|
15
|
+
'author-request/v2': 'author-request.v2.schema.json',
|
|
16
|
+
'author-result/v2': 'author-result.v2.schema.json',
|
|
17
|
+
};
|
|
18
|
+
const DEFAULT_SCHEMA_KEYS = {
|
|
19
|
+
'author-request': 'author-request/v2',
|
|
20
|
+
'author-result': 'author-result/v2',
|
|
17
21
|
};
|
|
18
22
|
|
|
19
23
|
const SCHEMAS = Object.fromEntries(
|
|
@@ -45,7 +49,7 @@ const DATE_TIME_PATTERN =
|
|
|
45
49
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
46
50
|
|
|
47
51
|
export function validateContract(kind, value, context = {}) {
|
|
48
|
-
const schema =
|
|
52
|
+
const schema = resolveContractSchema(kind, value);
|
|
49
53
|
if (!schema) {
|
|
50
54
|
return {
|
|
51
55
|
valid: false,
|
|
@@ -67,6 +71,27 @@ export function validateContract(kind, value, context = {}) {
|
|
|
67
71
|
return { valid: errors.length === 0, errors };
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
function resolveContractSchema(kind, value) {
|
|
75
|
+
if (SCHEMAS[kind]) {
|
|
76
|
+
return SCHEMAS[kind];
|
|
77
|
+
}
|
|
78
|
+
if (SCHEMAS_BY_ID.has(kind)) {
|
|
79
|
+
return SCHEMAS_BY_ID.get(kind);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaultKey = DEFAULT_SCHEMA_KEYS[kind];
|
|
83
|
+
if (!defaultKey) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const declared = isObject(value)
|
|
88
|
+
? SCHEMAS_BY_ID.get(value.schemaVersion)
|
|
89
|
+
: undefined;
|
|
90
|
+
return declared?.$id.startsWith(`explainer-kit.${kind}/`)
|
|
91
|
+
? declared
|
|
92
|
+
: SCHEMAS[defaultKey];
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
export function canonicalHash(value) {
|
|
71
96
|
return `sha256:${createHash('sha256')
|
|
72
97
|
.update(canonicalStringify(value))
|
|
@@ -452,46 +477,6 @@ function validateCrossRecord(kind, value, context, errors) {
|
|
|
452
477
|
}
|
|
453
478
|
}
|
|
454
479
|
|
|
455
|
-
if (kind === 'author-request') {
|
|
456
|
-
const requiredNarrative = Array.isArray(value.recipe?.requiredNarrative)
|
|
457
|
-
? value.recipe.requiredNarrative
|
|
458
|
-
: [];
|
|
459
|
-
const outlineIds = Array.isArray(value.narrativeOutline)
|
|
460
|
-
? value.narrativeOutline.map((section) => section?.id)
|
|
461
|
-
: [];
|
|
462
|
-
if (
|
|
463
|
-
requiredNarrative.length !== outlineIds.length ||
|
|
464
|
-
requiredNarrative.some((id, index) => outlineIds[index] !== id)
|
|
465
|
-
) {
|
|
466
|
-
add(
|
|
467
|
-
errors,
|
|
468
|
-
'$.narrativeOutline',
|
|
469
|
-
'narrative-outline-mismatch',
|
|
470
|
-
'Author request narrative outline must exactly match recipe requiredNarrative order.',
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
if (kind === 'author-result') {
|
|
476
|
-
for (const [index, section] of (Array.isArray(value.content?.sections)
|
|
477
|
-
? value.content.sections
|
|
478
|
-
: []
|
|
479
|
-
).entries()) {
|
|
480
|
-
if (
|
|
481
|
-
isObject(section) &&
|
|
482
|
-
typeof section.prose === 'string' &&
|
|
483
|
-
section.prose.trim().length === 0
|
|
484
|
-
) {
|
|
485
|
-
add(
|
|
486
|
-
errors,
|
|
487
|
-
`$.content.sections[${index}].prose`,
|
|
488
|
-
'empty-prose',
|
|
489
|
-
'Authored section prose must contain non-whitespace text.',
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
480
|
if (kind === 'manifest') {
|
|
496
481
|
const paths = [];
|
|
497
482
|
for (const artifact of Array.isArray(value.artifacts)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
const HEADER_PATTERN = /^graph\s+(TD|LR)$/;
|
|
2
|
+
const ID_PATTERN = '[A-Za-z][A-Za-z0-9_-]*';
|
|
3
|
+
const OPERAND_PATTERN = `${ID_PATTERN}(?:\\[(?:[^\\]"]|"[^"]*")*\\]|\\((?:[^\\)"]|"[^"]*")*\\)|\\{(?:[^\\}"]|"[^"]*")*\\})?`;
|
|
4
|
+
const EDGE_PATTERN = new RegExp(
|
|
5
|
+
`^(${OPERAND_PATTERN})\\s*(-->|---)(?:\\|([^|]*)\\|)?\\s*(${OPERAND_PATTERN})$`,
|
|
6
|
+
);
|
|
7
|
+
const DECLARATION_PATTERN = new RegExp(`^${OPERAND_PATTERN}$`);
|
|
8
|
+
const NODE_PATTERN = new RegExp(
|
|
9
|
+
`^(${ID_PATTERN})(?:\\[((?:[^\\]"]|"[^"]*")*)\\]|\\(((?:[^\\)"]|"[^"]*")*)\\)|\\{((?:[^\\}"]|"[^"]*")*)\\})?$`,
|
|
10
|
+
);
|
|
11
|
+
const UNSUPPORTED_PATTERN =
|
|
12
|
+
/^(?:subgraph|end\b|classDef\b|class\b|style\b|linkStyle\b|click\b|sequenceDiagram\b|stateDiagram(?:-v2)?\b|flowchart\b)/i;
|
|
13
|
+
|
|
14
|
+
export function parseDiagram(source) {
|
|
15
|
+
if (typeof source !== 'string') {
|
|
16
|
+
throw new TypeError('Diagram source must be a string.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lines = source
|
|
20
|
+
.replaceAll('\r\n', '\n')
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line && !line.startsWith('%%'));
|
|
24
|
+
const header = lines.shift() ?? '';
|
|
25
|
+
const headerMatch = header.match(HEADER_PATTERN);
|
|
26
|
+
if (!headerMatch) {
|
|
27
|
+
return degraded(
|
|
28
|
+
/^(?:sequenceDiagram|stateDiagram|flowchart)/i.test(header)
|
|
29
|
+
? `Unsupported diagram type: ${header || 'missing header'}.`
|
|
30
|
+
: 'Diagram must begin with “graph TD” or “graph LR”.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const nodes = new Map();
|
|
35
|
+
const edges = [];
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (UNSUPPORTED_PATTERN.test(line)) {
|
|
38
|
+
return degraded(`Unsupported diagram construct: ${line}.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const edge = line.match(EDGE_PATTERN);
|
|
42
|
+
if (edge) {
|
|
43
|
+
const from = parseNode(edge[1]);
|
|
44
|
+
const to = parseNode(edge[4]);
|
|
45
|
+
if (!from || !to) return degraded(`Unsupported diagram syntax: ${line}.`);
|
|
46
|
+
registerNode(nodes, from);
|
|
47
|
+
registerNode(nodes, to);
|
|
48
|
+
edges.push({
|
|
49
|
+
from: from.id,
|
|
50
|
+
to: to.id,
|
|
51
|
+
kind: edge[2] === '-->' ? 'arrow' : 'line',
|
|
52
|
+
label: unquote(edge[3]?.trim() ?? ''),
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (DECLARATION_PATTERN.test(line)) {
|
|
58
|
+
const node = parseNode(line);
|
|
59
|
+
if (!node) return degraded(`Unsupported diagram syntax: ${line}.`);
|
|
60
|
+
registerNode(nodes, node);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return degraded(`Unsupported diagram syntax: ${line}.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (nodes.size === 0) {
|
|
68
|
+
return degraded('Diagram requires at least one node declaration or edge.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
valid: true,
|
|
73
|
+
direction: headerMatch[1],
|
|
74
|
+
nodes: [...nodes.values()],
|
|
75
|
+
edges,
|
|
76
|
+
warnings: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function renderDiagram(source, { theme } = {}) {
|
|
81
|
+
const parsed = parseDiagram(source);
|
|
82
|
+
if (!parsed.valid) {
|
|
83
|
+
return {
|
|
84
|
+
html: `<div class="diagram-fallback" role="note"><p class="diagram-warning">${escapeHtml(parsed.warnings[0].message)}</p><pre><code>${escapeHtml(source)}</code></pre></div>`,
|
|
85
|
+
warnings: parsed.warnings,
|
|
86
|
+
degraded: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const horizontal = parsed.direction === 'LR';
|
|
91
|
+
const nodeWidth = 180;
|
|
92
|
+
const nodeHeight = 72;
|
|
93
|
+
const gap = 90;
|
|
94
|
+
const margin = 50;
|
|
95
|
+
const positions = new Map(
|
|
96
|
+
parsed.nodes.map((node, index) => [
|
|
97
|
+
node.id,
|
|
98
|
+
horizontal
|
|
99
|
+
? { x: margin + index * (nodeWidth + gap), y: margin }
|
|
100
|
+
: { x: margin, y: margin + index * (nodeHeight + gap) },
|
|
101
|
+
]),
|
|
102
|
+
);
|
|
103
|
+
const width = horizontal
|
|
104
|
+
? margin * 2 +
|
|
105
|
+
parsed.nodes.length * nodeWidth +
|
|
106
|
+
(parsed.nodes.length - 1) * gap
|
|
107
|
+
: margin * 2 + nodeWidth;
|
|
108
|
+
const height = horizontal
|
|
109
|
+
? margin * 2 + nodeHeight
|
|
110
|
+
: margin * 2 +
|
|
111
|
+
parsed.nodes.length * nodeHeight +
|
|
112
|
+
(parsed.nodes.length - 1) * gap;
|
|
113
|
+
const markerId = `diagram-arrow-${stableHash(source)}`;
|
|
114
|
+
const mode = theme?.modes?.[theme.defaultMode];
|
|
115
|
+
const panel = mode?.surface?.panel ?? '#ffffff';
|
|
116
|
+
const ink = mode?.ink?.primary ?? '#172033';
|
|
117
|
+
const accent = mode?.accent?.primary ?? '#365270';
|
|
118
|
+
const muted = mode?.ink?.muted ?? '#526071';
|
|
119
|
+
const edges = parsed.edges
|
|
120
|
+
.map((edge) =>
|
|
121
|
+
renderEdge(edge, positions, {
|
|
122
|
+
horizontal,
|
|
123
|
+
nodeWidth,
|
|
124
|
+
nodeHeight,
|
|
125
|
+
markerId,
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.join('');
|
|
129
|
+
const nodes = parsed.nodes
|
|
130
|
+
.map((node) =>
|
|
131
|
+
renderNode(node, positions.get(node.id), { nodeWidth, nodeHeight }),
|
|
132
|
+
)
|
|
133
|
+
.join('');
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
html: `<svg class="narrative-diagram" data-direction="${parsed.direction}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Diagram"><style>
|
|
137
|
+
.diagram-node-shape { fill: var(--panel, ${panel}); stroke: var(--accent, ${accent}); stroke-width: 2; }
|
|
138
|
+
.diagram-node-label { fill: var(--ink, ${ink}); font: 14px var(--sans, system-ui, sans-serif); text-anchor: middle; dominant-baseline: middle; }
|
|
139
|
+
.diagram-edge { fill: none; stroke: var(--muted, ${muted}); stroke-width: 2; }
|
|
140
|
+
.diagram-edge-label { fill: var(--muted, ${muted}); font: 12px var(--mono, ui-monospace, monospace); text-anchor: middle; }
|
|
141
|
+
</style><defs><marker id="${markerId}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--muted, ${muted})"></path></marker></defs>${edges}${nodes}</svg>`,
|
|
142
|
+
warnings: [],
|
|
143
|
+
degraded: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseNode(value) {
|
|
148
|
+
const match = value.match(NODE_PATTERN);
|
|
149
|
+
if (!match) return null;
|
|
150
|
+
const [, id, rectangle, rounded, diamond] = match;
|
|
151
|
+
const explicitLabel = rectangle ?? rounded ?? diamond;
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
label: unquote(explicitLabel ?? id),
|
|
155
|
+
shape:
|
|
156
|
+
diamond !== undefined
|
|
157
|
+
? 'diamond'
|
|
158
|
+
: rounded !== undefined
|
|
159
|
+
? 'rounded'
|
|
160
|
+
: 'rectangle',
|
|
161
|
+
explicit: explicitLabel !== undefined,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function registerNode(nodes, candidate) {
|
|
166
|
+
const current = nodes.get(candidate.id);
|
|
167
|
+
if (!current || candidate.explicit) nodes.set(candidate.id, candidate);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderNode(node, position, { nodeWidth, nodeHeight }) {
|
|
171
|
+
const centerX = position.x + nodeWidth / 2;
|
|
172
|
+
const centerY = position.y + nodeHeight / 2;
|
|
173
|
+
let shape;
|
|
174
|
+
if (node.shape === 'diamond') {
|
|
175
|
+
shape = `<polygon class="diagram-node-shape" points="${centerX},${position.y} ${position.x + nodeWidth},${centerY} ${centerX},${position.y + nodeHeight} ${position.x},${centerY}"></polygon>`;
|
|
176
|
+
} else {
|
|
177
|
+
shape = `<rect class="diagram-node-shape" x="${position.x}" y="${position.y}" width="${nodeWidth}" height="${nodeHeight}"${node.shape === 'rounded' ? ' rx="24"' : ''}></rect>`;
|
|
178
|
+
}
|
|
179
|
+
return `<g class="diagram-node" data-node="${escapeAttribute(node.id)}">${shape}<text class="diagram-node-label" x="${centerX}" y="${centerY}">${escapeHtml(node.label)}</text></g>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderEdge(
|
|
183
|
+
edge,
|
|
184
|
+
positions,
|
|
185
|
+
{ horizontal, nodeWidth, nodeHeight, markerId },
|
|
186
|
+
) {
|
|
187
|
+
const from = positions.get(edge.from);
|
|
188
|
+
const to = positions.get(edge.to);
|
|
189
|
+
const start = horizontal
|
|
190
|
+
? { x: from.x + nodeWidth, y: from.y + nodeHeight / 2 }
|
|
191
|
+
: { x: from.x + nodeWidth / 2, y: from.y + nodeHeight };
|
|
192
|
+
const end = horizontal
|
|
193
|
+
? { x: to.x, y: to.y + nodeHeight / 2 }
|
|
194
|
+
: { x: to.x + nodeWidth / 2, y: to.y };
|
|
195
|
+
const label = edge.label
|
|
196
|
+
? `<text class="diagram-edge-label" x="${(start.x + end.x) / 2}" y="${(start.y + end.y) / 2 - 8}">${escapeHtml(edge.label)}</text>`
|
|
197
|
+
: '';
|
|
198
|
+
return `<g class="diagram-connection" data-from="${escapeAttribute(edge.from)}" data-to="${escapeAttribute(edge.to)}"><path class="diagram-edge" d="M ${start.x} ${start.y} L ${end.x} ${end.y}"${edge.kind === 'arrow' ? ` marker-end="url(#${markerId})"` : ''}></path>${label}</g>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function degraded(message) {
|
|
202
|
+
return {
|
|
203
|
+
valid: false,
|
|
204
|
+
direction: null,
|
|
205
|
+
nodes: [],
|
|
206
|
+
edges: [],
|
|
207
|
+
warnings: [{ code: 'unsupported-diagram', message }],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function unquote(value) {
|
|
212
|
+
return value.startsWith('"') && value.endsWith('"')
|
|
213
|
+
? value.slice(1, -1)
|
|
214
|
+
: value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stableHash(value) {
|
|
218
|
+
let hash = 2166136261;
|
|
219
|
+
for (const character of value) {
|
|
220
|
+
hash ^= character.codePointAt(0);
|
|
221
|
+
hash = Math.imul(hash, 16777619);
|
|
222
|
+
}
|
|
223
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function escapeHtml(value) {
|
|
227
|
+
return String(value)
|
|
228
|
+
.replaceAll('&', '&')
|
|
229
|
+
.replaceAll('<', '<')
|
|
230
|
+
.replaceAll('>', '>')
|
|
231
|
+
.replaceAll('"', '"')
|
|
232
|
+
.replaceAll("'", ''');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function escapeAttribute(value) {
|
|
236
|
+
return escapeHtml(value);
|
|
237
|
+
}
|