@playcraft/cli 0.0.42 → 0.0.43

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.
Files changed (37) hide show
  1. package/dist/atom-plan/validate-asr-coverage.js +317 -0
  2. package/dist/commands/prad.js +61 -0
  3. package/dist/commands/remix.js +4 -2
  4. package/dist/commands/skills.js +24 -0
  5. package/dist/prad/atom-ref.js +23 -0
  6. package/dist/prad/check.js +377 -0
  7. package/dist/prad/check.test.js +27 -0
  8. package/dist/prad/explain.js +109 -0
  9. package/dist/prad/load-spec.js +23 -0
  10. package/dist/prad/paths.js +83 -0
  11. package/dist/prad/skills-index.js +60 -0
  12. package/package.json +3 -3
  13. package/project-template/.claude/agents/designer.md +26 -22
  14. package/project-template/.claude/agents/developer.md +2 -0
  15. package/project-template/.claude/agents/pm.md +3 -1
  16. package/project-template/.claude/agents/refs/designer-deliverable-spec.md +46 -7
  17. package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +21 -13
  18. package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +39 -9
  19. package/project-template/.claude/agents/refs/developer-dev-handoff.md +1 -1
  20. package/project-template/.claude/agents/refs/pm-workflow-detail.md +18 -2
  21. package/project-template/.claude/agents/refs/reviewer-convergence-eval.md +17 -5
  22. package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +42 -6
  23. package/project-template/.claude/agents/reviewer.md +8 -5
  24. package/project-template/.claude/agents/technical-artist.md +2 -0
  25. package/project-template/.claude/hooks/README.md +34 -6
  26. package/project-template/.claude/hooks/asr-coverage-validate.mjs +381 -0
  27. package/project-template/.claude/hooks/validate-workflow-stop.mjs +113 -7
  28. package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +76 -22
  29. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +19 -0
  30. package/project-template/docs/team/agent-runtime-matrix.md +71 -39
  31. package/project-template/docs/team/atom-plan-format.md +68 -0
  32. package/project-template/docs/team/core-model.md +20 -19
  33. package/project-template/docs/team/workflow-consistency-checklist.md +52 -0
  34. package/project-template/templates/atom-plan.template.json +18 -0
  35. package/project-template/templates/designer-log.template.md +78 -5
  36. package/project-template/templates/layout-spec.template.md +48 -8
  37. package/project-template/templates/ta-log.template.md +50 -22
@@ -0,0 +1,377 @@
1
+ import * as fs from 'node:fs';
2
+ import { atomIdFromInstance, parseAtomRef } from './atom-ref.js';
3
+ import { loadGateRequirements, loadSpineRegistry } from './load-spec.js';
4
+ import { walkPradDocument, parentEntityPath, resolvePath } from './paths.js';
5
+ import { effectiveAtomId, loadManifestImports, loadRegistryAtomIds, } from './skills-index.js';
6
+ function pushIssue(list, issue) {
7
+ list.push(issue);
8
+ }
9
+ function collectAtomRefsFromDoc(doc) {
10
+ const map = new Map();
11
+ const engine = doc.engine;
12
+ const engineRef = typeof engine === 'string' ? parseAtomRef(engine) : parseAtomRef(engine?.atom);
13
+ if (engineRef)
14
+ map.set(engineRef.id, engineRef);
15
+ const remixSpec = doc.meta?.remixSpec;
16
+ const remixAtom = atomIdFromInstance(remixSpec);
17
+ if (remixAtom) {
18
+ const ref = parseAtomRef(remixSpec.atom);
19
+ if (ref)
20
+ map.set(ref.id, ref);
21
+ }
22
+ for (const visit of walkPradDocument(doc)) {
23
+ if (visit.kind === 'entity' && visit.entity) {
24
+ const ref = parseAtomRef(visit.entity.atom);
25
+ if (ref)
26
+ map.set(ref.id, ref);
27
+ }
28
+ if (visit.mediaInstance) {
29
+ const ref = parseAtomRef(visit.mediaInstance.atom);
30
+ if (ref)
31
+ map.set(ref.id, ref);
32
+ }
33
+ }
34
+ return map;
35
+ }
36
+ function parseLedger(doc) {
37
+ const ledger = new Map();
38
+ const meta = doc.meta;
39
+ for (const entry of meta?.atomLedger ?? []) {
40
+ if (entry?.id)
41
+ ledger.set(entry.id, entry);
42
+ }
43
+ return ledger;
44
+ }
45
+ export function checkPradDocument(doc, options = {}) {
46
+ const errors = [];
47
+ const warnings = [];
48
+ const gate = options.gate ?? doc.meta?.gate ?? 4;
49
+ const meta = doc.meta;
50
+ const pradVersion = meta?.pradVersion;
51
+ if (!pradVersion && !options.legacy) {
52
+ pushIssue(warnings, {
53
+ code: 'PRAD-W001',
54
+ severity: 'warning',
55
+ path: 'meta.pradVersion',
56
+ message: 'Missing pradVersion; treat as legacy v0.1',
57
+ });
58
+ }
59
+ const isV02Plus = pradVersion === '0.2' || pradVersion === '0.2.1';
60
+ const isV021 = pradVersion === '0.2.1';
61
+ const registry = loadRegistryAtomIds(options.skillsDir);
62
+ const spineReg = loadSpineRegistry(options.specDir);
63
+ const gateReq = loadGateRequirements(options.specDir);
64
+ const gateConfig = gateReq.gates[String(gate)];
65
+ const docAtoms = collectAtomRefsFromDoc(doc);
66
+ const ledger = parseLedger(doc);
67
+ // S2: atom resolve
68
+ for (const [atomId, ref] of docAtoms) {
69
+ if (ref.resolve === 'draft') {
70
+ const issue = {
71
+ code: options.strictDrafts ? 'PRAD-E020' : 'PRAD-W020',
72
+ severity: options.strictDrafts ? 'error' : 'warning',
73
+ path: atomId,
74
+ message: `Draft atom "${atomId}"${ref.substitute ? ` (substitute: ${ref.substitute})` : ''}`,
75
+ };
76
+ if (ref.substitute && !registry.has(ref.substitute)) {
77
+ pushIssue(errors, {
78
+ code: 'PRAD-E023',
79
+ severity: 'error',
80
+ path: atomId,
81
+ message: `Draft substitute "${ref.substitute}" not in Skills registry`,
82
+ });
83
+ }
84
+ else if (!ref.substitute) {
85
+ pushIssue(errors, {
86
+ code: 'PRAD-E023',
87
+ severity: 'error',
88
+ path: atomId,
89
+ message: `Draft atom "${atomId}" missing substitute`,
90
+ });
91
+ }
92
+ else {
93
+ pushIssue(options.strictDrafts ? errors : warnings, issue);
94
+ }
95
+ }
96
+ else if (!registry.has(atomId)) {
97
+ pushIssue(errors, {
98
+ code: 'PRAD-E020',
99
+ severity: 'error',
100
+ path: atomId,
101
+ message: `Unknown registry atom "${atomId}"`,
102
+ });
103
+ }
104
+ }
105
+ // S2b: atomLedger consistency
106
+ if (isV02Plus && gate >= 4) {
107
+ if (!meta?.atomLedger?.length) {
108
+ pushIssue(errors, {
109
+ code: 'PRAD-E022',
110
+ severity: 'error',
111
+ path: 'meta.atomLedger',
112
+ message: 'Gate 4 requires meta.atomLedger',
113
+ });
114
+ }
115
+ }
116
+ for (const [atomId, ref] of docAtoms) {
117
+ const ledgerEntry = ledger.get(atomId);
118
+ if (isV02Plus && gate >= 2 && !ledgerEntry) {
119
+ pushIssue(warnings, {
120
+ code: 'PRAD-E022',
121
+ severity: 'warning',
122
+ path: 'meta.atomLedger',
123
+ message: `Atom "${atomId}" used in document but missing from atomLedger`,
124
+ });
125
+ }
126
+ if (ledgerEntry && ledgerEntry.resolve !== ref.resolve) {
127
+ pushIssue(errors, {
128
+ code: 'PRAD-E022',
129
+ severity: 'error',
130
+ path: 'meta.atomLedger',
131
+ message: `Ledger resolve for "${atomId}" (${ledgerEntry.resolve}) != node (${ref.resolve})`,
132
+ });
133
+ }
134
+ }
135
+ for (const [atomId, entry] of ledger) {
136
+ if (entry.resolve === 'draft' && !entry.substitute) {
137
+ pushIssue(errors, {
138
+ code: 'PRAD-E023',
139
+ severity: 'error',
140
+ path: 'meta.atomLedger',
141
+ message: `Ledger draft "${atomId}" missing substitute`,
142
+ });
143
+ }
144
+ if (entry.resolve === 'draft' && entry.substitute && !registry.has(entry.substitute)) {
145
+ pushIssue(errors, {
146
+ code: 'PRAD-E023',
147
+ severity: 'error',
148
+ path: 'meta.atomLedger',
149
+ message: `Ledger substitute "${entry.substitute}" for "${atomId}" not in registry`,
150
+ });
151
+ }
152
+ }
153
+ if (isV02Plus && gate >= 4 && gateConfig?.requiredMeta?.includes('atomLedger')) {
154
+ for (const [atomId, ref] of docAtoms) {
155
+ if (ref.resolve === 'draft' && gate >= 4) {
156
+ pushIssue(warnings, {
157
+ code: 'PRAD-W020',
158
+ severity: 'warning',
159
+ path: atomId,
160
+ message: `Gate 4 document still contains draft atom "${atomId}" (compile uses substitute)`,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ // S5: slot vs spine-registry
166
+ const presentSpines = new Set();
167
+ if (isV02Plus && gateConfig?.entitySlotRequired) {
168
+ for (const visit of walkPradDocument(doc)) {
169
+ if (visit.kind !== 'entity' || !visit.entity)
170
+ continue;
171
+ const slot = visit.entity.slot;
172
+ const sinceGate = visit.entity.sinceGate;
173
+ if (!slot?.spine) {
174
+ pushIssue(errors, {
175
+ code: 'PRAD-E025',
176
+ severity: 'error',
177
+ path: visit.path,
178
+ message: 'Entity missing slot.spine (required Gate 3+)',
179
+ });
180
+ continue;
181
+ }
182
+ if (!spineReg.spines[slot.spine]) {
183
+ pushIssue(errors, {
184
+ code: 'PRAD-E025',
185
+ severity: 'error',
186
+ path: visit.path,
187
+ message: `Unknown spine "${slot.spine}"`,
188
+ });
189
+ }
190
+ else {
191
+ presentSpines.add(slot.spine);
192
+ }
193
+ if (sinceGate != null && meta?.gate != null && sinceGate > meta.gate) {
194
+ pushIssue(errors, {
195
+ code: 'PRAD-E027',
196
+ severity: 'error',
197
+ path: visit.path,
198
+ message: `sinceGate ${sinceGate} > meta.gate ${meta.gate}`,
199
+ });
200
+ }
201
+ if (sinceGate != null && sinceGate > gate) {
202
+ pushIssue(errors, {
203
+ code: 'PRAD-E027',
204
+ severity: 'error',
205
+ path: visit.path,
206
+ message: `sinceGate ${sinceGate} > check gate ${gate}`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ for (const req of gateConfig?.requiredSpines ?? []) {
212
+ if (req.criticality !== 'required')
213
+ continue;
214
+ const spine = spineReg.spines[req.spine];
215
+ if (spine?.globalsKey) {
216
+ const globals = doc.globals;
217
+ if (!globals?.[spine.globalsKey]) {
218
+ pushIssue(errors, {
219
+ code: 'PRAD-E040',
220
+ severity: 'error',
221
+ path: `globals.${spine.globalsKey}`,
222
+ message: `Required spine ${req.spine} (globals.${spine.globalsKey}) missing`,
223
+ });
224
+ }
225
+ continue;
226
+ }
227
+ if (spine?.topLevelKey) {
228
+ if (!(spine.topLevelKey in doc)) {
229
+ pushIssue(errors, {
230
+ code: 'PRAD-E040',
231
+ severity: 'error',
232
+ path: spine.topLevelKey,
233
+ message: `Required spine ${req.spine} (${spine.topLevelKey}) missing`,
234
+ });
235
+ }
236
+ continue;
237
+ }
238
+ if (spine?.metaKey) {
239
+ const metaObj = doc.meta;
240
+ if (!metaObj?.[spine.metaKey]) {
241
+ pushIssue(errors, {
242
+ code: 'PRAD-E040',
243
+ severity: 'error',
244
+ path: `meta.${spine.metaKey}`,
245
+ message: `Required spine ${req.spine} (meta.${spine.metaKey}) missing`,
246
+ });
247
+ }
248
+ continue;
249
+ }
250
+ if (!presentSpines.has(req.spine)) {
251
+ pushIssue(errors, {
252
+ code: 'PRAD-E040',
253
+ severity: 'error',
254
+ path: req.spine,
255
+ message: `Required spine ${req.spine} not present on any entity`,
256
+ });
257
+ }
258
+ }
259
+ // S3b: bindAs on media (Gate 2+ when enabled)
260
+ if (isV02Plus && gate >= 2 && gateConfig?.bindAsRequiredOnMedia !== false) {
261
+ for (const visit of walkPradDocument(doc)) {
262
+ if (visit.kind === 'global') {
263
+ const inst = visit.mediaInstance;
264
+ if (!inst?.bindAs) {
265
+ pushIssue(gate >= 4 ? errors : warnings, {
266
+ code: 'PRAD-E026',
267
+ severity: gate >= 4 ? 'error' : 'warning',
268
+ path: visit.path,
269
+ message: 'Global atom instance missing bindAs',
270
+ });
271
+ }
272
+ continue;
273
+ }
274
+ if (visit.kind !== 'media' || !visit.mediaInstance || !visit.parentEntityAtom)
275
+ continue;
276
+ const bindAs = visit.mediaInstance.bindAs;
277
+ if (!bindAs) {
278
+ pushIssue(gate >= 4 ? errors : warnings, {
279
+ code: 'PRAD-E026',
280
+ severity: gate >= 4 ? 'error' : 'warning',
281
+ path: visit.path,
282
+ message: 'Media instance missing bindAs',
283
+ });
284
+ continue;
285
+ }
286
+ if (bindAs.startsWith('globals.'))
287
+ continue;
288
+ const bindTargetRef = visit.mediaInstance.bindTargetRef;
289
+ let targetEntityPath;
290
+ let targetEntityAtom;
291
+ if (bindTargetRef) {
292
+ if (!isV021) {
293
+ pushIssue(warnings, {
294
+ code: 'PRAD-W021',
295
+ severity: 'warning',
296
+ path: visit.path,
297
+ message: 'bindTargetRef ignored on pradVersion < 0.2.1',
298
+ });
299
+ targetEntityPath = parentEntityPath(visit.path);
300
+ targetEntityAtom = visit.parentEntityAtom;
301
+ }
302
+ else {
303
+ const targetVisit = resolvePath(doc, bindTargetRef);
304
+ if (!targetVisit || targetVisit.kind !== 'entity' || !targetVisit.entity) {
305
+ pushIssue(errors, {
306
+ code: 'PRAD-E028',
307
+ severity: 'error',
308
+ path: visit.path,
309
+ message: `bindTargetRef "${bindTargetRef}" is not a valid entity path`,
310
+ });
311
+ continue;
312
+ }
313
+ targetEntityPath = bindTargetRef;
314
+ targetEntityAtom = parseAtomRef(targetVisit.entity.atom) ?? undefined;
315
+ if (!targetEntityAtom)
316
+ continue;
317
+ }
318
+ }
319
+ else {
320
+ targetEntityPath = parentEntityPath(visit.path);
321
+ targetEntityAtom = visit.parentEntityAtom;
322
+ }
323
+ const targetEffective = effectiveAtomId(targetEntityAtom);
324
+ const imports = loadManifestImports(targetEffective, options.skillsDir);
325
+ const e026Severity = isV021 && gate >= 4 ? 'error' : 'warning';
326
+ const e026List = isV021 && gate >= 4 ? errors : warnings;
327
+ if (!imports || imports.bindAsSet.size === 0) {
328
+ if (isV021 && gate >= 4) {
329
+ pushIssue(e026List, {
330
+ code: 'PRAD-E026',
331
+ severity: e026Severity,
332
+ path: visit.path,
333
+ message: `bindAs "${bindAs}" targets ${targetEffective} which has no manifest imports`,
334
+ });
335
+ }
336
+ else if (imports && imports.bindAsSet.size > 0) {
337
+ // unreachable
338
+ }
339
+ continue;
340
+ }
341
+ if (!imports.bindAsSet.has(bindAs)) {
342
+ pushIssue(e026List, {
343
+ code: 'PRAD-E026',
344
+ severity: e026Severity,
345
+ path: visit.path,
346
+ message: `bindAs "${bindAs}" not in ${targetEffective} manifest imports (known: ${[...imports.bindAsSet].join(', ')})`,
347
+ });
348
+ }
349
+ }
350
+ }
351
+ // Gate 4 review
352
+ if (gate >= 4) {
353
+ const review = doc.review;
354
+ if (!review) {
355
+ pushIssue(errors, { code: 'PRAD-E050', severity: 'error', path: 'review', message: 'Missing review block' });
356
+ }
357
+ else if (review.pass !== true) {
358
+ pushIssue(errors, { code: 'PRAD-E050', severity: 'error', path: 'review.pass', message: 'review.pass must be true' });
359
+ }
360
+ }
361
+ return { ok: errors.length === 0, errors, warnings };
362
+ }
363
+ export function checkPradFile(filePath, options = {}) {
364
+ const raw = fs.readFileSync(filePath, 'utf-8');
365
+ let doc;
366
+ try {
367
+ doc = JSON.parse(raw);
368
+ }
369
+ catch {
370
+ return {
371
+ ok: false,
372
+ errors: [{ code: 'PRAD-E001', severity: 'error', path: filePath, message: 'Invalid JSON' }],
373
+ warnings: [],
374
+ };
375
+ }
376
+ return checkPradDocument(doc, options);
377
+ }
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { checkPradFile } from './check.js';
6
+ import { explainPradPath, formatExplain } from './explain.js';
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const mahjongSample = path.resolve(__dirname, '..', '..', '..', '..', 'docs', 'prad', 'samples', 'mahjong-gate4.atom-tree.json');
9
+ describe('prad check', () => {
10
+ it('mahjong gate4 v0.2 passes with warnings for draft atoms', () => {
11
+ if (!fs.existsSync(mahjongSample))
12
+ return;
13
+ const result = checkPradFile(mahjongSample, { gate: 4 });
14
+ expect(result.errors).toEqual([]);
15
+ expect(result.warnings.some((w) => w.code === 'PRAD-W020')).toBe(true);
16
+ expect(result.ok).toBe(true);
17
+ });
18
+ it('explain play_btn path', () => {
19
+ if (!fs.existsSync(mahjongSample))
20
+ return;
21
+ const doc = JSON.parse(fs.readFileSync(mahjongSample, 'utf-8'));
22
+ const result = explainPradPath(doc, 'scenes.entry.ui_layer.play_btn');
23
+ expect(result.found).toBe(true);
24
+ expect(result.slot?.spine).toBe('medium.hook.play');
25
+ expect(formatExplain(result)).toContain('medium.hook.play');
26
+ });
27
+ });
@@ -0,0 +1,109 @@
1
+ import { parseAtomRef } from './atom-ref.js';
2
+ import { loadSpineRegistry } from './load-spec.js';
3
+ import { resolvePath } from './paths.js';
4
+ import { effectiveAtomId, loadManifestImports, loadRegistryAtomIds } from './skills-index.js';
5
+ export function explainPradPath(doc, logicalPath, options = {}) {
6
+ const visit = resolvePath(doc, logicalPath);
7
+ if (!visit) {
8
+ return { path: logicalPath, found: false };
9
+ }
10
+ const registry = loadRegistryAtomIds(options.skillsDir);
11
+ const spineReg = loadSpineRegistry(options.specDir);
12
+ const result = { path: logicalPath, found: true, kind: visit.kind };
13
+ if (visit.kind === 'entity' && visit.entity) {
14
+ const atom = parseAtomRef(visit.entity.atom);
15
+ result.atom = atom ?? undefined;
16
+ result.effectiveAtomId = atom ? effectiveAtomId(atom) : undefined;
17
+ result.registryHit = atom ? registry.has(atom.id) : undefined;
18
+ if (atom?.resolve === 'draft' && atom.substitute) {
19
+ result.registryHit = registry.has(atom.substitute);
20
+ }
21
+ result.slot = visit.entity.slot;
22
+ result.sinceGate = visit.entity.sinceGate;
23
+ const spineId = visit.entity.slot?.spine;
24
+ if (spineId && spineReg.spines[spineId]) {
25
+ result.spineDescription = spineReg.spines[spineId].description;
26
+ }
27
+ const props = visit.entity.props;
28
+ if (props) {
29
+ const refs = [];
30
+ for (const [k, v] of Object.entries(props)) {
31
+ if (v && typeof v === 'object' && 'ref' in v)
32
+ refs.push(`${k} → ${v.ref}`);
33
+ if (typeof v === 'string' && k.endsWith('Ref'))
34
+ refs.push(`${k} → ${v}`);
35
+ if (k === 'binding' && typeof v === 'string')
36
+ refs.push(`binding → ${v}`);
37
+ }
38
+ if (refs.length)
39
+ result.refs = refs;
40
+ }
41
+ }
42
+ if (visit.kind === 'media' && visit.mediaInstance) {
43
+ const atom = parseAtomRef(visit.mediaInstance.atom);
44
+ result.atom = atom ?? undefined;
45
+ result.effectiveAtomId = atom ? effectiveAtomId(atom) : undefined;
46
+ result.bindAs = visit.mediaInstance.bindAs;
47
+ const bindTargetRef = visit.mediaInstance.bindTargetRef;
48
+ if (bindTargetRef) {
49
+ result.bindTargetRef = bindTargetRef;
50
+ const targetVisit = resolvePath(doc, bindTargetRef);
51
+ if (targetVisit?.entity) {
52
+ const targetAtom = parseAtomRef(targetVisit.entity.atom);
53
+ if (targetAtom) {
54
+ const targetEff = effectiveAtomId(targetAtom);
55
+ result.bindTargetEffectiveAtom = targetEff;
56
+ const imports = loadManifestImports(targetEff, options.skillsDir);
57
+ if (imports)
58
+ result.manifestImportSlots = [...imports.bindAsSet];
59
+ }
60
+ }
61
+ }
62
+ else if (visit.parentEntityAtom) {
63
+ const parentEff = effectiveAtomId(visit.parentEntityAtom);
64
+ result.parentEffectiveAtom = parentEff;
65
+ const imports = loadManifestImports(parentEff, options.skillsDir);
66
+ if (imports)
67
+ result.manifestImportSlots = [...imports.bindAsSet];
68
+ }
69
+ }
70
+ if (visit.kind === 'global' && visit.mediaInstance) {
71
+ result.atom = parseAtomRef(visit.mediaInstance.atom) ?? undefined;
72
+ result.bindAs = visit.mediaInstance.bindAs;
73
+ }
74
+ return result;
75
+ }
76
+ export function formatExplain(result) {
77
+ const lines = [`path: ${result.path}`, `found: ${result.found}`];
78
+ if (!result.found)
79
+ return lines.join('\n');
80
+ if (result.kind)
81
+ lines.push(`kind: ${result.kind}`);
82
+ if (result.slot)
83
+ lines.push(`slot: ${JSON.stringify(result.slot)}`);
84
+ if (result.sinceGate != null)
85
+ lines.push(`sinceGate: ${result.sinceGate}`);
86
+ if (result.atom)
87
+ lines.push(`atom: ${JSON.stringify(result.atom)}`);
88
+ if (result.effectiveAtomId)
89
+ lines.push(`effectiveAtomId: ${result.effectiveAtomId}`);
90
+ if (result.registryHit != null)
91
+ lines.push(`registryHit: ${result.registryHit}`);
92
+ if (result.spineDescription) {
93
+ lines.push(`spineDescription: ${result.spineDescription.zh ?? result.spineDescription.en ?? ''}`);
94
+ }
95
+ if (result.bindAs)
96
+ lines.push(`bindAs: ${result.bindAs}`);
97
+ if (result.bindTargetRef)
98
+ lines.push(`bindTargetRef: ${result.bindTargetRef}`);
99
+ if (result.parentEffectiveAtom)
100
+ lines.push(`parentEffectiveAtom: ${result.parentEffectiveAtom}`);
101
+ if (result.bindTargetEffectiveAtom)
102
+ lines.push(`bindTargetEffectiveAtom: ${result.bindTargetEffectiveAtom}`);
103
+ if (result.manifestImportSlots?.length) {
104
+ lines.push(`manifestImportSlots: ${result.manifestImportSlots.join(', ')}`);
105
+ }
106
+ if (result.refs?.length)
107
+ lines.push(`refs:\n ${result.refs.join('\n ')}`);
108
+ return lines.join('\n');
109
+ }
@@ -0,0 +1,23 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ export function resolvePradSpecDir(customDir) {
6
+ if (customDir)
7
+ return path.resolve(customDir);
8
+ const mono = path.resolve(__dirname, '..', '..', '..', '..', 'docs', 'prad', 'spec');
9
+ if (fs.existsSync(path.join(mono, 'spine-registry.v1.json')))
10
+ return mono;
11
+ const cwd = path.join(process.cwd(), 'docs', 'prad', 'spec');
12
+ if (fs.existsSync(path.join(cwd, 'spine-registry.v1.json')))
13
+ return cwd;
14
+ return mono;
15
+ }
16
+ export function loadSpineRegistry(specDir) {
17
+ const file = path.join(resolvePradSpecDir(specDir), 'spine-registry.v1.json');
18
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
19
+ }
20
+ export function loadGateRequirements(specDir) {
21
+ const file = path.join(resolvePradSpecDir(specDir), 'gate-requirements.v1.json');
22
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
23
+ }
@@ -0,0 +1,83 @@
1
+ import { parseAtomRef } from './atom-ref.js';
2
+ function visitEntities(basePath, entities, out, parentEntityAtom) {
3
+ if (!Array.isArray(entities))
4
+ return;
5
+ for (const raw of entities) {
6
+ if (!raw || typeof raw !== 'object')
7
+ continue;
8
+ const entity = raw;
9
+ const id = entity.id;
10
+ if (typeof id !== 'string')
11
+ continue;
12
+ const entityPath = `${basePath}.${id}`;
13
+ const atomRef = parseAtomRef(entity.atom);
14
+ out.push({ path: entityPath, kind: 'entity', entity, parentEntityAtom });
15
+ const props = entity.props;
16
+ if (props && typeof props === 'object') {
17
+ for (const [key, val] of Object.entries(props)) {
18
+ if (val && typeof val === 'object' && 'atom' in val) {
19
+ out.push({
20
+ path: `${entityPath}.props.${key}`,
21
+ kind: 'media',
22
+ entity,
23
+ parentEntityAtom: atomRef ?? undefined,
24
+ mediaPropKey: key,
25
+ mediaInstance: val,
26
+ });
27
+ }
28
+ }
29
+ }
30
+ if (Array.isArray(entity.children)) {
31
+ visitEntities(entityPath, entity.children, out, atomRef ?? undefined);
32
+ }
33
+ }
34
+ }
35
+ export function walkPradDocument(doc) {
36
+ const out = [];
37
+ const globals = doc.globals;
38
+ if (globals && typeof globals === 'object') {
39
+ for (const [key, val] of Object.entries(globals)) {
40
+ if (val && typeof val === 'object' && 'atom' in val) {
41
+ out.push({
42
+ path: `globals.${key}`,
43
+ kind: 'global',
44
+ globalKey: key,
45
+ mediaInstance: val,
46
+ });
47
+ }
48
+ }
49
+ }
50
+ const scenes = doc.scenes;
51
+ if (Array.isArray(scenes)) {
52
+ for (const scene of scenes) {
53
+ if (!scene || typeof scene !== 'object')
54
+ continue;
55
+ const sceneId = scene.id;
56
+ if (typeof sceneId !== 'string')
57
+ continue;
58
+ const layers = scene.layers;
59
+ if (!Array.isArray(layers))
60
+ continue;
61
+ for (const layer of layers) {
62
+ if (!layer || typeof layer !== 'object')
63
+ continue;
64
+ const layerId = layer.id;
65
+ if (typeof layerId !== 'string')
66
+ continue;
67
+ const base = `scenes.${sceneId}.${layerId}`;
68
+ visitEntities(base, layer.entities ?? [], out);
69
+ }
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+ export function resolvePath(doc, logicalPath) {
75
+ return walkPradDocument(doc).find((v) => v.path === logicalPath);
76
+ }
77
+ /** Media path `scenes.x.y.z.props.key` → entity path `scenes.x.y.z` */
78
+ export function parentEntityPath(mediaPath) {
79
+ const idx = mediaPath.lastIndexOf('.props.');
80
+ if (idx === -1)
81
+ return undefined;
82
+ return mediaPath.slice(0, idx);
83
+ }
@@ -0,0 +1,60 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolveSkillsDirs } from '../atom-plan/validate-atom-plan.js';
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ function allSkillsDirs(customDir) {
7
+ const dirs = resolveSkillsDirs(customDir);
8
+ const monoFromPrad = path.resolve(__dirname, '..', '..', '..', 'skills', 'skills');
9
+ if (fs.existsSync(monoFromPrad))
10
+ dirs.push(monoFromPrad);
11
+ return [...new Set(dirs)];
12
+ }
13
+ /** Load set of registry atom ids from Skills directories. */
14
+ export function loadRegistryAtomIds(skillsDir) {
15
+ const ids = new Set();
16
+ for (const dir of allSkillsDirs(skillsDir)) {
17
+ if (!fs.existsSync(dir))
18
+ continue;
19
+ for (const name of fs.readdirSync(dir)) {
20
+ const manifestPath = path.join(dir, name, 'manifest.json');
21
+ if (!fs.existsSync(manifestPath))
22
+ continue;
23
+ try {
24
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
25
+ if (manifest.atomId)
26
+ ids.add(manifest.atomId);
27
+ }
28
+ catch {
29
+ // skip invalid manifest
30
+ }
31
+ }
32
+ }
33
+ return ids;
34
+ }
35
+ export function loadManifestImports(atomId, skillsDir) {
36
+ for (const dir of allSkillsDirs(skillsDir)) {
37
+ const manifestPath = path.join(dir, atomId, 'manifest.json');
38
+ if (!fs.existsSync(manifestPath))
39
+ continue;
40
+ try {
41
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
42
+ const bindAsSet = new Set();
43
+ for (const imp of manifest.imports ?? []) {
44
+ if (imp.bindAs)
45
+ bindAsSet.add(imp.bindAs);
46
+ }
47
+ return { atomId: manifest.atomId ?? atomId, bindAsSet };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ /** Effective atom id for compile: draft uses substitute when present. */
56
+ export function effectiveAtomId(ref) {
57
+ if (ref.resolve === 'draft' && ref.substitute)
58
+ return ref.substitute;
59
+ return ref.id;
60
+ }