@ssobig/writer-cli 0.2.0

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 (73) hide show
  1. package/README.md +35 -0
  2. package/asset-repository.js +278 -0
  3. package/config.js +14 -0
  4. package/package.json +28 -0
  5. package/project-runtime.js +102 -0
  6. package/storage-path.js +110 -0
  7. package/templates/mystery-v1/authoring-view-preference.js +34 -0
  8. package/templates/mystery-v1/character-perspective-preview.js +61 -0
  9. package/templates/mystery-v1/component-asset-operations.js +103 -0
  10. package/templates/mystery-v1/component-autosave.js +121 -0
  11. package/templates/mystery-v1/component-catalog-contract.js +340 -0
  12. package/templates/mystery-v1/component-checkpoint-history.js +145 -0
  13. package/templates/mystery-v1/component-contract.js +90 -0
  14. package/templates/mystery-v1/component-draft-operations.js +313 -0
  15. package/templates/mystery-v1/component-field-contracts.js +595 -0
  16. package/templates/mystery-v1/component-id-policy.js +64 -0
  17. package/templates/mystery-v1/component-manager.js +396 -0
  18. package/templates/mystery-v1/component-navigation-counts.js +64 -0
  19. package/templates/mystery-v1/component-registry.js +205 -0
  20. package/templates/mystery-v1/component-renderers.js +139 -0
  21. package/templates/mystery-v1/component-storage-contract.js +237 -0
  22. package/templates/mystery-v1/external-update-coordinator.js +91 -0
  23. package/templates/mystery-v1/output-clue-card-layout.js +46 -0
  24. package/templates/mystery-v1/page-header.js +26 -0
  25. package/templates/mystery-v1/render-ui-state.js +76 -0
  26. package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
  27. package/templates/mystery-v1/tab-bar.js +87 -0
  28. package/templates/mystery-v1/view-component-contract.js +152 -0
  29. package/templates/mystery-v1/view-component-registry.js +44 -0
  30. package/templates/mystery-v1/view-component-runtime.js +95 -0
  31. package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
  32. package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
  33. package/tools/writer-cli/package-lock.json +121 -0
  34. package/tools/writer-cli/package.json +22 -0
  35. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
  36. package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
  37. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
  38. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
  39. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
  40. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
  41. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
  42. package/tools/writer-cli/src/agent-paths.cjs +114 -0
  43. package/tools/writer-cli/src/agent-service.cjs +496 -0
  44. package/tools/writer-cli/src/asset-policy.cjs +113 -0
  45. package/tools/writer-cli/src/auth.cjs +655 -0
  46. package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
  47. package/tools/writer-cli/src/command-registry.cjs +152 -0
  48. package/tools/writer-cli/src/commands.cjs +841 -0
  49. package/tools/writer-cli/src/corpus.cjs +83 -0
  50. package/tools/writer-cli/src/daemon-app.cjs +106 -0
  51. package/tools/writer-cli/src/daemon-client.cjs +187 -0
  52. package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
  53. package/tools/writer-cli/src/daemon-runner.cjs +97 -0
  54. package/tools/writer-cli/src/daemon-server.cjs +378 -0
  55. package/tools/writer-cli/src/diagnostics.cjs +235 -0
  56. package/tools/writer-cli/src/domain.cjs +731 -0
  57. package/tools/writer-cli/src/errors.cjs +47 -0
  58. package/tools/writer-cli/src/gateway.cjs +357 -0
  59. package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
  60. package/tools/writer-cli/src/json-patch.cjs +98 -0
  61. package/tools/writer-cli/src/json.cjs +26 -0
  62. package/tools/writer-cli/src/local-index-cache.cjs +139 -0
  63. package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
  64. package/tools/writer-cli/src/local-index-query.cjs +304 -0
  65. package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
  66. package/tools/writer-cli/src/local-index-storage.cjs +284 -0
  67. package/tools/writer-cli/src/local-index.cjs +199 -0
  68. package/tools/writer-cli/src/mutations.cjs +722 -0
  69. package/tools/writer-cli/src/platform-runner.cjs +55 -0
  70. package/tools/writer-cli/src/project-import.cjs +485 -0
  71. package/tools/writer-cli/src/skill-manager.cjs +255 -0
  72. package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
  73. package/tools/writer-cli/src/update-gate.cjs +102 -0
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ const { cliError } = require("./errors.cjs");
4
+ const { effectiveCacheDir, requestDaemon } = require("./daemon-runner.cjs");
5
+ const { resolveAgentPaths } = require("./agent-paths.cjs");
6
+
7
+ const IN_PROCESS_METHODS = Object.freeze({
8
+ "writer.sync": "sync",
9
+ "writer.search": "search",
10
+ "writer.read": "read",
11
+ "writer.get": "get",
12
+ "writer.plan_replace": "planReplace",
13
+ "writer.apply": "apply",
14
+ "writer.purge": "purge",
15
+ "writer.logout": "logout"
16
+ });
17
+
18
+ async function requestInProcess(method, params = {}, options = {}) {
19
+ const serviceMethod = IN_PROCESS_METHODS[String(method || "")];
20
+ if (!serviceMethod) throw cliError("E_DAEMON_METHOD", `Windows in-process backend가 지원하지 않는 method입니다: ${String(method || "")}`);
21
+ const platform = String(options.platform || process.platform);
22
+ const environment = options.environment || process.env;
23
+ const paths = resolveAgentPaths({
24
+ cacheDir: effectiveCacheDir(options, environment),
25
+ platform,
26
+ environment,
27
+ homeDirectory: options.homeDirectory
28
+ });
29
+ const serviceFactory = options.serviceFactory || (serviceOptions => require("./agent-service.cjs").createAgentService(serviceOptions));
30
+ const service = serviceFactory({
31
+ platform,
32
+ cacheDir: paths.cacheDir,
33
+ environment,
34
+ homeDirectory: options.homeDirectory,
35
+ fs: options.fs,
36
+ auth: options.auth,
37
+ authOptions: options.authOptions,
38
+ gatewayFactory: options.gatewayFactory,
39
+ indexFactory: options.indexFactory,
40
+ realtime: options.realtime
41
+ });
42
+ try {
43
+ return await service.handle(serviceMethod, params);
44
+ } finally {
45
+ await service.close();
46
+ }
47
+ }
48
+
49
+ function requestWriterService(method, params = {}, options = {}) {
50
+ return String(options.platform || process.platform) === "win32"
51
+ ? requestInProcess(method, params, options)
52
+ : requestDaemon(method, params, options);
53
+ }
54
+
55
+ module.exports = Object.freeze({ IN_PROCESS_METHODS, requestInProcess, requestWriterService });
@@ -0,0 +1,485 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const catalogContract = require("../../../templates/mystery-v1/component-catalog-contract.js");
7
+ const componentContract = require("../../../templates/mystery-v1/component-storage-contract.js");
8
+ const viewContract = require("../../../templates/mystery-v1/view-component-contract.js");
9
+ const { inspectAssetFile } = require("./asset-policy.cjs");
10
+ const { clone, equalJson, sha256 } = require("./json.cjs");
11
+ const { cliError } = require("./errors.cjs");
12
+
13
+ const IMPORT_SOURCE_FORMAT = "true-writer-case-v1";
14
+ const IMPORT_PROFILE = catalogContract.IMPORT_PROFILE;
15
+ const CAPABILITIES = Object.freeze({ viewSelection: Object.freeze({ authoring: "json", preview: "raw", design: "theme", validation: "schema" }) });
16
+ const IMPORT_VIEW_LABELS = Object.freeze({ "ssobig.ai-response": "AI 응답 작업 화면" });
17
+
18
+ const IMPORT_DESCRIPTORS = catalogContract.forImportProfile(IMPORT_PROFILE)
19
+ .sort((left, right) => Number(right.required) - Number(left.required));
20
+ const COMPONENT_BLUEPRINTS = Object.freeze(IMPORT_DESCRIPTORS.map(descriptor => blueprint(
21
+ descriptor.defaultInstanceId,
22
+ descriptor.templateId,
23
+ descriptor.tabLabel,
24
+ descriptor.required,
25
+ { internal: descriptor.internal }
26
+ )));
27
+ const VIEW_BLUEPRINTS = Object.freeze(IMPORT_DESCRIPTORS.filter(descriptor => descriptor.view).map(descriptor => view(
28
+ descriptor.view.instanceId,
29
+ descriptor.view.templateId,
30
+ IMPORT_VIEW_LABELS[descriptor.templateId] || descriptor.view.label,
31
+ descriptor.view.rendererId,
32
+ descriptor.defaultInstanceId,
33
+ descriptor.view.bindings.map(item => ({
34
+ key: item.key,
35
+ data: catalogContract.get(item.dataTemplateId).defaultInstanceId,
36
+ mode: item.accessMode,
37
+ required: item.required
38
+ }))
39
+ )));
40
+
41
+ const CHARACTER_IDS = Object.freeze({
42
+ "강공정": "kang-gongjeong",
43
+ "윤스타": "yoon-star",
44
+ "박근성": "park-geunseong",
45
+ "김PD": "kim-pd"
46
+ });
47
+ const CHARACTER_COLORS = Object.freeze({
48
+ "kang-gongjeong": "#497fa8",
49
+ "yoon-star": "#8068a8",
50
+ "park-geunseong": "#5f8f62",
51
+ "kim-pd": "#a86b49"
52
+ });
53
+ const CASE_DOCUMENTS = Object.freeze([
54
+ "00-overview.md", "01-truth.md", "02-characters.md", "03-timeline.md",
55
+ "04-clues.md", "05-ssobig-flow.md", "06-host-guide.md", "07-playtest-report.md"
56
+ ]);
57
+ const ASSET_FILES = Object.freeze({
58
+ "background-main.webp": "workspace-background",
59
+ "cover.webp": "poster",
60
+ "logo.webp": "logo",
61
+ "role-kang-gongjeong.webp": "character-kang-gongjeong",
62
+ "role-yoon-star.webp": "character-yoon-star",
63
+ "role-park-geunseong.webp": "character-park-geunseong",
64
+ "role-kim-pd.webp": "character-kim-pd",
65
+ "share.webp": "share"
66
+ });
67
+
68
+ function blueprint(instanceId, templateId, tabLabel, required = false, options = {}) {
69
+ return Object.freeze({ instanceId, templateId, tabLabel, required, internal: options.internal === true });
70
+ }
71
+
72
+ function view(instanceId, templateId, label, rendererId, primaryData, bindings = null) {
73
+ return Object.freeze({
74
+ instanceId,
75
+ templateId,
76
+ label,
77
+ rendererId,
78
+ primaryData,
79
+ bindings: Object.freeze(bindings || [{ key: "primary", data: primaryData, mode: "read_write", required: true }])
80
+ });
81
+ }
82
+
83
+ function isObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
84
+
85
+ function readRequired(filePath, label, fsApi = fs) {
86
+ try { return fsApi.readFileSync(filePath); }
87
+ catch (error) { throw cliError("E_FILE_NOT_FOUND", `${label} 파일을 읽을 수 없습니다: ${filePath}`, null, error); }
88
+ }
89
+
90
+ function readText(filePath, label, fsApi = fs) { return readRequired(filePath, label, fsApi).toString("utf8"); }
91
+
92
+ function parseJson(filePath, label, fsApi = fs) {
93
+ const source = readText(filePath, label, fsApi);
94
+ try { return JSON.parse(source); }
95
+ catch (error) { throw cliError("E_INVALID_JSON", `${label} 파일이 올바른 JSON이 아닙니다: ${filePath}`, null, error); }
96
+ }
97
+
98
+ function snapshotFile(root, filePath, kind, fsApi = fs) {
99
+ const buffer = readRequired(filePath, kind, fsApi);
100
+ const relativePath = path.relative(root, filePath).split(path.sep).join("/");
101
+ if (!relativePath || relativePath.startsWith("../") || path.isAbsolute(relativePath)) throw cliError("E_VALIDATION", `원본 파일이 case root 밖에 있습니다: ${filePath}`);
102
+ return Object.freeze({ path: path.resolve(filePath), relativePath, kind, size: buffer.length, contentHash: sha256(buffer) });
103
+ }
104
+
105
+ function text(value) { return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value, null, 2); }
106
+
107
+ function markdownSections(source) {
108
+ const sections = [];
109
+ let current = null;
110
+ for (const line of String(source || "").split(/\r?\n/)) {
111
+ const heading = line.match(/^##\s+(.+?)\s*$/);
112
+ if (heading) {
113
+ current = { title: heading[1], lines: [] };
114
+ sections.push(current);
115
+ } else if (current) current.lines.push(line);
116
+ }
117
+ return sections;
118
+ }
119
+
120
+ function parseTimeline(source) {
121
+ const events = [];
122
+ for (const section of markdownSections(source)) {
123
+ if (!/사건 전|사건 당일/.test(section.title)) continue;
124
+ for (const line of section.lines) {
125
+ if (!/^\|/.test(line) || /^\|\s*(?:시간|---)/.test(line)) continue;
126
+ const cells = line.split("|").slice(1, -1).map(cell => cell.trim());
127
+ if (cells.length < 4 || cells.every(cell => /^-+$/.test(cell))) continue;
128
+ const memories = {};
129
+ for (const [name, characterId] of Object.entries(CHARACTER_IDS)) {
130
+ if (cells[2].includes(name)) memories[characterId] = { included: true, text: cells[3] };
131
+ }
132
+ events.push({
133
+ id: crypto.randomUUID(),
134
+ time: cells[0],
135
+ location: section.title,
136
+ truth: cells[1],
137
+ memories
138
+ });
139
+ }
140
+ }
141
+ if (!events.length) throw cliError("E_VALIDATION", "03-timeline.md에서 사건 타임라인 표를 찾지 못했습니다.");
142
+ return events;
143
+ }
144
+
145
+ function characterComponent(characterSource) {
146
+ const characters = {};
147
+ const names = {};
148
+ const order = [];
149
+ for (const [name, characterId] of Object.entries(CHARACTER_IDS)) {
150
+ const source = characterSource?.[name];
151
+ if (!isObject(source)) throw cliError("E_VALIDATION", `캐릭터 원본을 찾을 수 없습니다: ${name}`);
152
+ order.push(characterId);
153
+ names[characterId] = name;
154
+ const customSections = [
155
+ { title: "자기소개", body: text(source["자기소개"]) },
156
+ { title: "개별 서사", body: text(source["서사1"]) },
157
+ { title: "인물 목표", body: [text(source["목적1"]), text(source["목적2"])].filter(Boolean).join("\n\n") },
158
+ { title: "추가 서사", body: text(source["서사2"]) },
159
+ { title: "기억", body: text(source["기억"]) },
160
+ { title: "관계도", body: text(source["관계도"]) }
161
+ ].filter(section => section.body.trim()).map(section => ({ id: crypto.randomUUID(), ...section }));
162
+ characters[characterId] = {
163
+ isPlayable: true,
164
+ tag: "플레이어 캐릭터",
165
+ color: CHARACTER_COLORS[characterId],
166
+ customSections,
167
+ containers: [{ id: crypto.randomUUID(), role: "primary", title: "기본 정보", sectionIds: customSections.map(section => section.id) }],
168
+ authorNote: ""
169
+ };
170
+ }
171
+ return {
172
+ characters, names, order, customCharacters: [], deletedColumns: [],
173
+ enabledOptionalFields: ["image", "color", "tag", "authorNote"]
174
+ };
175
+ }
176
+
177
+ function progressComponent(flowSource) {
178
+ if (!isObject(flowSource) || !Object.keys(flowSource).length) throw cliError("E_VALIDATION", "진행 원본이 비어 있습니다.");
179
+ return {
180
+ steps: Object.entries(flowSource).map(([name, item], index) => {
181
+ const source = isObject(item) ? item : {};
182
+ const actions = [
183
+ source.guide && `진행 안내\n${source.guide}`,
184
+ source.rule && `규칙\n${source.rule}`,
185
+ source.notice && `주의\n${source.notice}`
186
+ ].filter(Boolean).join("\n\n");
187
+ return {
188
+ id: crypto.randomUUID(),
189
+ name,
190
+ time: "",
191
+ description: [text(source.script), text(source.script_19)].filter(Boolean).join("\n\n"),
192
+ actions
193
+ };
194
+ })
195
+ };
196
+ }
197
+
198
+ function cluesComponent(resourceSource, writerNote) {
199
+ if (!isObject(resourceSource)) throw cliError("E_VALIDATION", "단서 resource 원본이 객체가 아닙니다.");
200
+ const clues = [];
201
+ Object.entries(resourceSource).forEach(([sourceId, item]) => {
202
+ const source = isObject(item) ? item : {};
203
+ const name = text(source.title) || sourceId;
204
+ clues.push({
205
+ id: crypto.randomUUID(),
206
+ name,
207
+ title: name,
208
+ location: source.verification === undefined ? "1차 조사" : "2차 조사",
209
+ description: text(source.content),
210
+ secret: text(source.verification),
211
+ tags: [source.verification === undefined ? "자료" : "제보"],
212
+ color: source.verification === undefined ? "#497fa8" : "#a86b49",
213
+ confirmed: true,
214
+ sourceId
215
+ });
216
+ });
217
+ return {
218
+ clues,
219
+ enabledOptionalFields: ["confirmed", "image", "location", "tags", "color", "secret"],
220
+ writerNote
221
+ };
222
+ }
223
+
224
+ function firstNonEmpty(...values) {
225
+ return values.map(text).find(value => value.trim()) || "";
226
+ }
227
+
228
+ function addEndingPair(conditions, outcomes, code, value, fallbackCondition = "") {
229
+ const source = isObject(value) ? value : {};
230
+ const body = isObject(value) ? firstNonEmpty(source.body, source.description, source.text) : text(value);
231
+ const title = isObject(value) ? text(source.title) || code : code;
232
+ const condition = isObject(value) ? text(source.condition_text ?? source.condition) : fallbackCondition;
233
+ const id = crypto.randomUUID();
234
+ conditions.push({ id, code, condition });
235
+ outcomes.push({ id, title, body, customSections: [] });
236
+ }
237
+
238
+ function endingComponent(endingSource) {
239
+ if (!isObject(endingSource)) throw cliError("E_VALIDATION", "엔딩 원본이 객체가 아닙니다.");
240
+ const conditions = [];
241
+ const outcomes = [];
242
+ for (const [id, value] of Object.entries(endingSource)) {
243
+ if (id === "character" && isObject(value)) {
244
+ for (const [name, variants] of Object.entries(value)) {
245
+ const characterId = CHARACTER_IDS[name] || `character-${conditions.length + 1}`;
246
+ for (const [variant, outcome] of Object.entries(isObject(variants) ? variants : {})) {
247
+ addEndingPair(conditions, outcomes, `${characterId}-${variant}`, outcome, `${name}의 ${variant} 조건`);
248
+ }
249
+ }
250
+ } else if (id === "program" && isObject(value)) {
251
+ for (const [variant, outcome] of Object.entries(value)) addEndingPair(conditions, outcomes, `program-${variant}`, outcome);
252
+ } else addEndingPair(conditions, outcomes, id, value);
253
+ }
254
+ return { conditions, outcomes };
255
+ }
256
+
257
+ function component(instanceId, data) {
258
+ const blueprintValue = COMPONENT_BLUEPRINTS.find(item => item.instanceId === instanceId);
259
+ if (!blueprintValue) throw cliError("E_CONFIGURATION", `Import Component blueprint가 없습니다: ${instanceId}`);
260
+ return { instanceId, templateId: blueprintValue.templateId, data };
261
+ }
262
+
263
+ function normalizeImportComponents(input) {
264
+ if (!Array.isArray(input)) throw cliError("E_VALIDATION", "Import components는 배열이어야 합니다.");
265
+ const seenIds = new Set();
266
+ const seenTemplates = new Set();
267
+ const output = input.map(value => {
268
+ if (!isObject(value)) throw cliError("E_VALIDATION", "Import Component 항목은 객체여야 합니다.");
269
+ const instanceId = String(value.instanceId || "");
270
+ const blueprintValue = COMPONENT_BLUEPRINTS.find(item => item.instanceId === instanceId);
271
+ if (!blueprintValue
272
+ || String(value.templateId || "") !== blueprintValue.templateId
273
+ || !isObject(value.data)) {
274
+ throw cliError("E_VALIDATION", `Import Component identity 또는 data가 올바르지 않습니다: ${instanceId || "(비어 있음)"}`);
275
+ }
276
+ if (seenIds.has(instanceId) || seenTemplates.has(blueprintValue.templateId)) throw cliError("E_VALIDATION", `Import Component가 중복되었습니다: ${instanceId}`);
277
+ seenIds.add(instanceId);
278
+ seenTemplates.add(blueprintValue.templateId);
279
+ const spec = componentContract.componentSpec(blueprintValue.templateId);
280
+ const invalid = spec?.validate(value.data);
281
+ if (!spec || invalid) throw cliError("E_VALIDATION", invalid || `지원되지 않는 Component입니다: ${blueprintValue.templateId}`);
282
+ return Object.freeze({ instanceId, templateId: blueprintValue.templateId, data: clone(value.data) });
283
+ });
284
+ for (const required of COMPONENT_BLUEPRINTS.filter(item => item.required)) {
285
+ if (!seenIds.has(required.instanceId)) throw cliError("E_VALIDATION", `필수 Import Component가 없습니다: ${required.instanceId}`);
286
+ }
287
+ return Object.freeze(output);
288
+ }
289
+
290
+ function importCompositionRows(components, projectId = "11111111-1111-4111-8111-111111111111") {
291
+ const normalized = normalizeImportComponents(components);
292
+ const byId = new Map(normalized.map(item => [item.instanceId, item]));
293
+ const activeBlueprints = COMPONENT_BLUEPRINTS.filter(item => byId.has(item.instanceId));
294
+ const componentRows = activeBlueprints.map((definition, order) => ({
295
+ project_id: projectId,
296
+ version_number: 1,
297
+ instance_id: definition.instanceId,
298
+ template_id: definition.templateId,
299
+ tab_label: definition.tabLabel,
300
+ sort_order: order,
301
+ is_enabled: true,
302
+ is_archived: false,
303
+ is_required: definition.required,
304
+ is_removable: !definition.required,
305
+ editor_view_id: "json",
306
+ preview_view_id: "raw",
307
+ capabilities: clone(CAPABILITIES),
308
+ config: definition.internal ? { hidden: true } : {},
309
+ data: clone(byId.get(definition.instanceId).data),
310
+ revision: 1
311
+ }));
312
+ const enabledViews = VIEW_BLUEPRINTS.filter(definition => byId.has(definition.primaryData));
313
+ const viewRows = enabledViews.map((definition, order) => ({
314
+ view_instance_id: definition.instanceId,
315
+ view_template_id: definition.templateId,
316
+ label: definition.label,
317
+ description: "",
318
+ renderer_id: definition.rendererId,
319
+ supported_modes: ["authoring", "preview"],
320
+ sort_order: order,
321
+ is_enabled: true,
322
+ config: {},
323
+ revision: 1
324
+ }));
325
+ const bindingRows = enabledViews.flatMap(definition => definition.bindings
326
+ .filter(binding => byId.has(binding.data) && (binding.required || byId.has(binding.data)))
327
+ .map((binding, order) => ({
328
+ view_instance_id: definition.instanceId,
329
+ binding_key: binding.key,
330
+ data_instance_id: binding.data,
331
+ access_mode: binding.mode,
332
+ is_required: binding.required,
333
+ sort_order: order
334
+ })));
335
+ const set = { project_id: projectId, version_number: 1, revision: 1, canonical_status: "active", component_checksum: "sha256:import-plan" };
336
+ const composition = componentContract.validateActiveComposition(set, componentRows);
337
+ if (!composition.ok) throw cliError("E_VALIDATION", composition.error.message, null, composition.error);
338
+ const views = viewContract.validateProjectViews(viewRows, bindingRows, composition.instances);
339
+ if (!views.ok) throw cliError("E_VALIDATION", views.error.message, null, views.error);
340
+ return Object.freeze({ components: normalized, componentRows, viewRows, bindingRows, instances: composition.instances, views: views.views });
341
+ }
342
+
343
+ function loadTrueWriterCase(input, options = {}) {
344
+ const fsApi = options.fs || fs;
345
+ const root = path.resolve(String(input.sourceRoot || ""));
346
+ const sourceJsonPath = path.resolve(String(input.sourceJson || ""));
347
+ if (!root || !sourceJsonPath) throw cliError("E_USAGE", "--source-root와 --source-json이 필요합니다.");
348
+ const docs = Object.fromEntries(CASE_DOCUMENTS.map(name => [name, readText(path.join(root, name), name, fsApi)]));
349
+ const source = parseJson(sourceJsonPath, "SsoBig source", fsApi);
350
+ const jsonData = source?.customParam?.jsonData;
351
+ if (!isObject(jsonData?.game_set) || !isObject(jsonData.resource)) throw cliError("E_VALIDATION", "SsoBig source에 game_set/resource가 없습니다.");
352
+ const game = isObject(source.game) ? source.game : {};
353
+ const title = String(input.title || game.title || jsonData.game_set.game_name || "").trim();
354
+ if (!title) throw cliError("E_VALIDATION", "작품명을 확인할 수 없습니다.");
355
+
356
+ const overviewIntro = markdownSections(docs["00-overview.md"]).find(section => section.title === "한 문장 소개")?.lines.join("\n").trim() || "";
357
+ const progress = progressComponent(jsonData.game_set.flow);
358
+ const ending = endingComponent(jsonData.game_set.ending);
359
+ const timelineEvents = parseTimeline(docs["03-timeline.md"]);
360
+ const resources = Object.keys(jsonData.resource).length;
361
+ const components = normalizeImportComponents([
362
+ component("basic", {
363
+ title,
364
+ intro: overviewIntro,
365
+ detail: docs["00-overview.md"],
366
+ minPlayers: Number(game.maxPlayerCount || 4),
367
+ maxPlayers: Number(game.maxPlayerCount || 4),
368
+ brandColor: "#7a4f64",
369
+ backgroundColor: "#f4f0f2",
370
+ theme: "light",
371
+ font: String(game.fontFamily || "Noto Sans KR")
372
+ }),
373
+ component("progress", progress),
374
+ component("common", {
375
+ sections: {
376
+ overview: docs["00-overview.md"],
377
+ prologue: text(jsonData.game_set.flow?.["00 - 프롤로그"]?.script)
378
+ },
379
+ customSections: [],
380
+ sectionOrder: ["overview", "prologue"],
381
+ baseTitles: { overview: "작품 개요", prologue: "프롤로그" },
382
+ deletedBaseSections: []
383
+ }),
384
+ component("characters", characterComponent(jsonData.game_set.character)),
385
+ component("assets", { assets: {} }),
386
+ component("clues", cluesComponent(jsonData.resource, docs["04-clues.md"])),
387
+ component("timeline", { events: timelineEvents }),
388
+ component("ending", ending),
389
+ component("postgame", {
390
+ truth: [{ id: crypto.randomUUID(), title: "사건의 진실", body: docs["01-truth.md"] }],
391
+ epilogue: [{ id: crypto.randomUUID(), title: "에필로그", body: text(jsonData.game_set.flow?.["14 - 에필로그"]?.script) }],
392
+ intro: { truth: "디자이너 전용 정답 및 사건의 전말", epilogue: "게임 종료 후 공개 문서" }
393
+ }),
394
+ component("author-notes", {
395
+ notes: [
396
+ ["character-design", "캐릭터 설계 원문", docs["02-characters.md"]],
397
+ ["timeline-design", "타임라인 설계 원문", docs["03-timeline.md"]],
398
+ ["flow-design", "게임 진행 설계 원문", docs["05-ssobig-flow.md"]],
399
+ ["host-guide", "호스트 가이드", docs["06-host-guide.md"]],
400
+ ["playtest-report", "플레이테스트 보고서", docs["07-playtest-report.md"]]
401
+ ].map(([, noteTitle, body]) => ({ id: crypto.randomUUID(), title: noteTitle, body }))
402
+ })
403
+ ]);
404
+ importCompositionRows(components);
405
+
406
+ const documentPaths = CASE_DOCUMENTS.map(name => path.join(root, name));
407
+ const assetFiles = [];
408
+ for (const [name, assetId] of Object.entries(ASSET_FILES)) {
409
+ const filePath = path.join(root, "assets", name);
410
+ const inspected = inspectAssetFile(filePath, assetId, { fs: fsApi });
411
+ assetFiles.push({ assetId, path: inspected.path, name: inspected.name, type: inspected.type, extension: inspected.extension, size: inspected.size, contentHash: inspected.contentHash });
412
+ }
413
+ const sourceAsset = inspectAssetFile(sourceJsonPath, "source-runtime-json", { fs: fsApi });
414
+ assetFiles.push({ assetId: "source-runtime-json", path: sourceAsset.path, name: sourceAsset.name, type: sourceAsset.type, extension: sourceAsset.extension, size: sourceAsset.size, contentHash: sourceAsset.contentHash });
415
+ const sourceFiles = [
416
+ ...documentPaths.map(filePath => snapshotFile(root, filePath, "canonical-document", fsApi)),
417
+ snapshotFile(root, sourceJsonPath, "runtime-json", fsApi),
418
+ ...assetFiles.filter(item => item.assetId !== "source-runtime-json").map(item => snapshotFile(root, item.path, "asset", fsApi))
419
+ ].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
420
+ const sourceHash = sha256(sourceFiles.map(item => ({ relativePath: item.relativePath, contentHash: item.contentHash, size: item.size })));
421
+ const coverage = Object.freeze({
422
+ canonicalDocuments: CASE_DOCUMENTS.length,
423
+ characters: Object.keys(jsonData.game_set.character || {}).length,
424
+ progressSteps: progress.steps.length,
425
+ resources,
426
+ endingGroups: Object.keys(jsonData.game_set.ending || {}).length,
427
+ endingOutcomes: ending.outcomes.length,
428
+ timelineEvents: timelineEvents.length,
429
+ components: components.length,
430
+ assets: assetFiles.length,
431
+ unexplained: 0
432
+ });
433
+ return Object.freeze({
434
+ title,
435
+ components,
436
+ assets: Object.freeze(assetFiles.map(clone)),
437
+ coverage,
438
+ source: Object.freeze({
439
+ format: IMPORT_SOURCE_FORMAT,
440
+ profile: IMPORT_PROFILE,
441
+ root,
442
+ jsonPath: sourceJsonPath,
443
+ sourceHash,
444
+ sourceGameId: String(game.documentId || game.id || ""),
445
+ sourceLastModified: String(game.lastModifiedDate || ""),
446
+ files: Object.freeze(sourceFiles)
447
+ })
448
+ });
449
+ }
450
+
451
+ function assertSourceSnapshot(source, options = {}) {
452
+ const fsApi = options.fs || fs;
453
+ if (!isObject(source) || source.format !== IMPORT_SOURCE_FORMAT || source.profile !== IMPORT_PROFILE || !Array.isArray(source.files)) {
454
+ throw cliError("E_INVALID_PLAN", "Import source snapshot이 올바르지 않습니다.");
455
+ }
456
+ const observed = source.files.map(file => snapshotFile(String(source.root || ""), String(file.path || ""), String(file.kind || "source"), fsApi));
457
+ if (!equalJson(observed, source.files)) throw cliError("E_CONFLICT", "plan 생성 후 true-writer 원본 파일이 변경되었습니다. 새 import plan을 만들어 주세요.");
458
+ const sourceHash = sha256(observed.map(item => ({ relativePath: item.relativePath, contentHash: item.contentHash, size: item.size })));
459
+ if (sourceHash !== source.sourceHash) throw cliError("E_CONFLICT", "plan의 true-writer source hash가 현재 파일과 다릅니다.");
460
+ return source;
461
+ }
462
+
463
+ function sourceProvenance(source, coverage) {
464
+ return Object.freeze({
465
+ format: IMPORT_SOURCE_FORMAT,
466
+ profile: IMPORT_PROFILE,
467
+ sourceHash: String(source.sourceHash || ""),
468
+ sourceGameId: String(source.sourceGameId || ""),
469
+ sourceLastModified: String(source.sourceLastModified || ""),
470
+ fileCount: Array.isArray(source.files) ? source.files.length : 0,
471
+ coverage: clone(coverage)
472
+ });
473
+ }
474
+
475
+ module.exports = Object.freeze({
476
+ IMPORT_SOURCE_FORMAT,
477
+ IMPORT_PROFILE,
478
+ COMPONENT_BLUEPRINTS,
479
+ VIEW_BLUEPRINTS,
480
+ loadTrueWriterCase,
481
+ normalizeImportComponents,
482
+ importCompositionRows,
483
+ assertSourceSnapshot,
484
+ sourceProvenance
485
+ });