@ssobig/writer-cli 0.3.0 → 0.3.3
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/README.md +11 -4
- package/asset-repository.js +7 -2
- package/package.json +1 -1
- package/templates/mystery-v1/authoring-view-preference.js +25 -2
- package/templates/mystery-v1/codemirror6-runtime.min.js +1 -1
- package/templates/mystery-v1/component-asset-operations.js +19 -6
- package/templates/mystery-v1/component-catalog-contract.js +2 -2
- package/templates/mystery-v1/component-field-contracts.js +98 -2
- package/templates/mystery-v1/component-navigation-counts.js +6 -1
- package/templates/mystery-v1/component-renderers.js +1 -1
- package/templates/mystery-v1/component-storage-contract.js +28 -9
- package/templates/mystery-v1/markdown-document-model.js +458 -0
- package/templates/mystery-v1/markdown-image-editor.js +336 -0
- package/templates/mystery-v1/markdown-live-editor.js +1246 -110
- package/templates/mystery-v1/markdown-toolbar.js +514 -0
- package/templates/mystery-v1/timeline-model.js +235 -0
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +12 -13
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
- package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
- package/tools/writer-cli/src/command-registry.cjs +24 -23
- package/tools/writer-cli/src/commands.cjs +22 -1
- package/tools/writer-cli/src/domain.cjs +46 -0
- package/tools/writer-cli/src/project-import.cjs +15 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchTimelineModel = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const MINUTES_PER_DAY = 1440;
|
|
9
|
+
const DEFAULT_LEVEL_NAME = "타임라인";
|
|
10
|
+
const DEFAULT_GAP_MINUTES = 60;
|
|
11
|
+
|
|
12
|
+
function isObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
|
|
13
|
+
function text(value) { return typeof value === "string" ? value : ""; }
|
|
14
|
+
function compareNumbers(left, right) { return left === right ? 0 : left < right ? -1 : 1; }
|
|
15
|
+
|
|
16
|
+
// events는 사건이 시간을 소유하는 legacy 모양, timeLevels/entries는 항목이 시간을 소유하는 v2 모양이다.
|
|
17
|
+
function detectShape(data) {
|
|
18
|
+
if (!isObject(data)) return "invalid";
|
|
19
|
+
const events = Array.isArray(data.events);
|
|
20
|
+
const levels = Array.isArray(data.timeLevels);
|
|
21
|
+
const entries = Array.isArray(data.entries);
|
|
22
|
+
if (events && !levels && !entries) return "legacy";
|
|
23
|
+
if (!events && levels && entries) return "v2";
|
|
24
|
+
return "invalid";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveIdFactory(options) {
|
|
28
|
+
if (typeof options?.idFactory === "function") return options.idFactory;
|
|
29
|
+
const cryptoApi = typeof globalThis !== "undefined" ? globalThis.crypto : null;
|
|
30
|
+
if (!cryptoApi || typeof cryptoApi.randomUUID !== "function") throw new Error("UUID 생성 기능을 사용할 수 없어 타임라인을 변환할 수 없습니다.");
|
|
31
|
+
return () => cryptoApi.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function memoryEntries(value) { return isObject(value) ? Object.entries(value) : []; }
|
|
35
|
+
|
|
36
|
+
// legacy 변환은 시간 문자열을 해석하지 않고 timeLabel에 원문을 그대로 보존한다.
|
|
37
|
+
function legacyDescriptors(data) {
|
|
38
|
+
const descriptors = [];
|
|
39
|
+
for (const event of data.events) {
|
|
40
|
+
const base = { timeLabel: text(event?.time), location: text(event?.location), eventGroupId: event?.id };
|
|
41
|
+
// 본문이 비어 있어도 사건 행의 시간·장소를 보존하기 위해 truth 항목은 항상 만든다.
|
|
42
|
+
descriptors.push({ ...base, laneKind: "truth", laneRef: "", text: text(event?.truth), included: true });
|
|
43
|
+
for (const [characterId, memory] of memoryEntries(event?.memories)) {
|
|
44
|
+
descriptors.push({ ...base, laneKind: "character", laneRef: characterId, text: text(memory?.text), included: memory?.included === true });
|
|
45
|
+
}
|
|
46
|
+
for (const [sourceKey, memory] of memoryEntries(event?.unassignedMemories)) {
|
|
47
|
+
descriptors.push({ ...base, laneKind: "unresolved", laneRef: sourceKey, text: text(memory?.text), included: memory?.included === true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return descriptors;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function entryDescriptor(entry) {
|
|
54
|
+
const lane = isObject(entry?.lane) ? entry.lane : {};
|
|
55
|
+
const laneKind = text(lane.kind);
|
|
56
|
+
return {
|
|
57
|
+
laneKind,
|
|
58
|
+
laneRef: text(laneKind === "character" ? lane.characterId : laneKind === "unresolved" ? lane.sourceKey : ""),
|
|
59
|
+
timeLabel: text(entry?.timeLabel),
|
|
60
|
+
location: text(entry?.location),
|
|
61
|
+
text: text(entry?.text),
|
|
62
|
+
included: entry?.included === true,
|
|
63
|
+
eventGroupId: entry?.eventGroupId
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function laneOf(descriptor) {
|
|
68
|
+
if (descriptor.laneKind === "character") return { kind: "character", characterId: descriptor.laneRef };
|
|
69
|
+
if (descriptor.laneKind === "unresolved") return { kind: "unresolved", sourceKey: descriptor.laneRef };
|
|
70
|
+
return { kind: "truth" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function convertLegacyTimeline(data, options = {}) {
|
|
74
|
+
if (detectShape(data) !== "legacy") throw new Error("legacy 타임라인 data가 아닙니다.");
|
|
75
|
+
const nextId = resolveIdFactory(options);
|
|
76
|
+
const level = { id: nextId(), name: DEFAULT_LEVEL_NAME, mode: "manual" };
|
|
77
|
+
const entries = legacyDescriptors(data).map(descriptor => {
|
|
78
|
+
const entry = {
|
|
79
|
+
id: nextId(),
|
|
80
|
+
timeLevelId: level.id,
|
|
81
|
+
lane: laneOf(descriptor),
|
|
82
|
+
when: { kind: "unspecified" },
|
|
83
|
+
timeLabel: descriptor.timeLabel,
|
|
84
|
+
location: descriptor.location,
|
|
85
|
+
text: descriptor.text,
|
|
86
|
+
included: descriptor.included
|
|
87
|
+
};
|
|
88
|
+
if (typeof descriptor.eventGroupId === "string") entry.eventGroupId = descriptor.eventGroupId;
|
|
89
|
+
return entry;
|
|
90
|
+
});
|
|
91
|
+
return { timeLevels: [level], entries };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 이관 CLI가 사람 검토 리포트로 쓰는 무손실 전수 대조다.
|
|
95
|
+
function compareLegacyToV2(legacyData, v2Data) {
|
|
96
|
+
const counts = { truthEntries: 0, memoryEntries: 0, unresolvedEntries: 0, includedTrue: 0 };
|
|
97
|
+
const mismatches = [];
|
|
98
|
+
if (detectShape(legacyData) !== "legacy") mismatches.push("원본이 legacy 타임라인 data가 아닙니다.");
|
|
99
|
+
if (detectShape(v2Data) !== "v2") mismatches.push("대상이 v2 타임라인 data가 아닙니다.");
|
|
100
|
+
if (mismatches.length) return { ok: false, counts, mismatches };
|
|
101
|
+
|
|
102
|
+
for (const entry of v2Data.entries) {
|
|
103
|
+
const descriptor = entryDescriptor(entry);
|
|
104
|
+
if (descriptor.laneKind === "truth") counts.truthEntries += 1;
|
|
105
|
+
if (descriptor.laneKind === "character") counts.memoryEntries += 1;
|
|
106
|
+
if (descriptor.laneKind === "unresolved") counts.unresolvedEntries += 1;
|
|
107
|
+
if (descriptor.included) counts.includedTrue += 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const expected = legacyDescriptors(legacyData);
|
|
111
|
+
if (expected.length !== v2Data.entries.length) {
|
|
112
|
+
mismatches.push(`항목 수가 다릅니다: 원본 ${expected.length}개, 변환 ${v2Data.entries.length}개`);
|
|
113
|
+
}
|
|
114
|
+
const levelIds = new Set(v2Data.timeLevels.map(level => level.id));
|
|
115
|
+
for (let index = 0; index < Math.min(expected.length, v2Data.entries.length); index += 1) {
|
|
116
|
+
const before = expected[index];
|
|
117
|
+
const after = entryDescriptor(v2Data.entries[index]);
|
|
118
|
+
for (const name of ["laneKind", "laneRef", "timeLabel", "location", "text", "included", "eventGroupId"]) {
|
|
119
|
+
if (before[name] !== after[name]) mismatches.push(`${index}번째 항목의 ${name}이(가) 다릅니다: ${JSON.stringify(before[name])} → ${JSON.stringify(after[name])}`);
|
|
120
|
+
}
|
|
121
|
+
if (!levelIds.has(v2Data.entries[index].timeLevelId)) mismatches.push(`${index}번째 항목이 존재하지 않는 시간 레벨을 가리킵니다.`);
|
|
122
|
+
}
|
|
123
|
+
return { ok: mismatches.length === 0, counts, mismatches };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function clockMinute(entry) {
|
|
127
|
+
const when = isObject(entry?.when) ? entry.when : {};
|
|
128
|
+
if (when.kind !== "clock" || !Number.isSafeInteger(when.startMinute)) return null;
|
|
129
|
+
const dayOffset = Number.isSafeInteger(when.dayOffset) ? when.dayOffset : 0;
|
|
130
|
+
const endMinute = Number.isSafeInteger(when.endMinute) ? when.endMinute : when.startMinute;
|
|
131
|
+
return { dayOffset, startMinute: when.startMinute, endMinute, start: dayOffset * MINUTES_PER_DAY + when.startMinute, end: dayOffset * MINUTES_PER_DAY + endMinute };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 저장 데이터는 바꾸지 않는 보기 전용 순서다.
|
|
135
|
+
function orderedEntries(v2Data) {
|
|
136
|
+
if (detectShape(v2Data) !== "v2") throw new Error("v2 타임라인 data가 아닙니다.");
|
|
137
|
+
const levelOrder = new Map(v2Data.timeLevels.map((level, index) => [level.id, index]));
|
|
138
|
+
return v2Data.entries
|
|
139
|
+
.map((entry, index) => {
|
|
140
|
+
const clock = clockMinute(entry);
|
|
141
|
+
return {
|
|
142
|
+
entry,
|
|
143
|
+
index,
|
|
144
|
+
level: levelOrder.has(entry.timeLevelId) ? levelOrder.get(entry.timeLevelId) : Number.POSITIVE_INFINITY,
|
|
145
|
+
minute: clock ? clock.start : Number.POSITIVE_INFINITY
|
|
146
|
+
};
|
|
147
|
+
})
|
|
148
|
+
.sort((left, right) => compareNumbers(left.level, right.level) || compareNumbers(left.minute, right.minute) || compareNumbers(left.index, right.index))
|
|
149
|
+
.map(item => item.entry);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function clockItems(entries) {
|
|
153
|
+
return (Array.isArray(entries) ? entries : [])
|
|
154
|
+
.map((entry, index) => ({ entry, index, clock: clockMinute(entry) }))
|
|
155
|
+
.filter(item => item.clock)
|
|
156
|
+
.sort((left, right) => compareNumbers(left.clock.start, right.clock.start) || compareNumbers(left.index, right.index));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// [start, end) 구간이 겹치는 묶음. endMinute이 없는 항목은 1분 점으로 취급해 시작 시각만 비교한다.
|
|
160
|
+
function overlapGroups(entries) {
|
|
161
|
+
const byLevel = new Map();
|
|
162
|
+
for (const item of clockItems(entries)) {
|
|
163
|
+
const levelId = item.entry.timeLevelId;
|
|
164
|
+
if (!byLevel.has(levelId)) byLevel.set(levelId, []);
|
|
165
|
+
byLevel.get(levelId).push(item);
|
|
166
|
+
}
|
|
167
|
+
const groups = [];
|
|
168
|
+
for (const items of byLevel.values()) {
|
|
169
|
+
let current = null;
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
const exclusiveEnd = Math.max(item.clock.end, item.clock.start + 1);
|
|
172
|
+
if (current && item.clock.start < current.exclusiveEnd) {
|
|
173
|
+
current.entryIds.push(item.entry.id);
|
|
174
|
+
current.startMinute = Math.min(current.startMinute, item.clock.start);
|
|
175
|
+
current.endMinute = Math.max(current.endMinute, item.clock.end);
|
|
176
|
+
current.exclusiveEnd = Math.max(current.exclusiveEnd, exclusiveEnd);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (current && current.entryIds.length > 1) groups.push(current);
|
|
180
|
+
current = { entryIds: [item.entry.id], startMinute: item.clock.start, endMinute: item.clock.end, exclusiveEnd };
|
|
181
|
+
}
|
|
182
|
+
if (current && current.entryIds.length > 1) groups.push(current);
|
|
183
|
+
}
|
|
184
|
+
return groups.map(group => ({ entryIds: group.entryIds, startMinute: group.startMinute, endMinute: group.endMinute }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 시작 시각 공백이 gapMinutes를 넘으면 분리한 로컬 시간 묶음.
|
|
188
|
+
// startMinute/endMinute은 묶음의 dayOffset을 기준으로 한 로컬 분이며, 날짜를 넘는 묶음은 endMinute이 1439를 넘을 수 있다.
|
|
189
|
+
function deriveClusters(entries, options = {}) {
|
|
190
|
+
const gapMinutes = Number.isFinite(options.gapMinutes) ? options.gapMinutes : DEFAULT_GAP_MINUTES;
|
|
191
|
+
const clusters = [];
|
|
192
|
+
let current = null;
|
|
193
|
+
for (const item of clockItems(entries)) {
|
|
194
|
+
if (current && item.clock.start - current.end <= gapMinutes) {
|
|
195
|
+
current.entryIds.push(item.entry.id);
|
|
196
|
+
current.end = Math.max(current.end, item.clock.end);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (current) clusters.push(current);
|
|
200
|
+
current = { dayOffset: item.clock.dayOffset, start: item.clock.start, end: item.clock.end, entryIds: [item.entry.id] };
|
|
201
|
+
}
|
|
202
|
+
if (current) clusters.push(current);
|
|
203
|
+
return clusters.map(cluster => ({
|
|
204
|
+
startMinute: cluster.start - cluster.dayOffset * MINUTES_PER_DAY,
|
|
205
|
+
endMinute: cluster.end - cluster.dayOffset * MINUTES_PER_DAY,
|
|
206
|
+
dayOffset: cluster.dayOffset,
|
|
207
|
+
entryIds: cluster.entryIds
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 항목이 표시할 장소 이름. placeIds가 가리키는 장소를 이동 경로 순서 그대로 돌려주고,
|
|
212
|
+
// 그 결과가 비면 전환기 fallback인 location 문자열을 쓴다. 둘 다 비면 빈 배열이다.
|
|
213
|
+
function entryPlaceNames(data, entry) {
|
|
214
|
+
const places = isObject(data) && Array.isArray(data.places) ? data.places : [];
|
|
215
|
+
const names = new Map(places
|
|
216
|
+
.filter(place => isObject(place) && typeof place.id === "string")
|
|
217
|
+
.map(place => [place.id.toLowerCase(), text(place.name)]));
|
|
218
|
+
const referenced = (Array.isArray(entry?.placeIds) ? entry.placeIds : [])
|
|
219
|
+
.map(id => (typeof id === "string" ? text(names.get(id.toLowerCase())) : ""))
|
|
220
|
+
.filter(name => name.trim());
|
|
221
|
+
if (referenced.length) return referenced;
|
|
222
|
+
const location = text(entry?.location).trim();
|
|
223
|
+
return location ? [location] : [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 활성 런타임이 중립 이름으로 소비하는 정규화 진입점: 어떤 등록 모양이든 v2 data를 돌려준다.
|
|
227
|
+
function normalizedTimelineData(data, options = {}) {
|
|
228
|
+
const shape = detectShape(data);
|
|
229
|
+
if (shape === "v2") return data;
|
|
230
|
+
if (shape === "legacy") return convertLegacyTimeline(data, options);
|
|
231
|
+
throw new Error("타임라인 data가 등록된 모양이 아닙니다.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Object.freeze({ detectShape, convertLegacyTimeline, compareLegacyToV2, normalizedTimelineData, entryPlaceNames, orderedEntries, overlapGroups, deriveClusters });
|
|
235
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssobig/writer-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ssobig/writer-cli",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.3",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@supabase/supabase-js": "2.110.9"
|
|
12
12
|
},
|
|
@@ -5,7 +5,7 @@ description: Safely discover and use the public SSOBIG WRITER CLI for manuscript
|
|
|
5
5
|
|
|
6
6
|
# SSOBIG WRITER CLI
|
|
7
7
|
|
|
8
|
-
Use this skill when a task asks you to inspect or change a Writer project through `ssobig-writer`. Treat the CLI, not this document, as the source of truth for command syntax.
|
|
8
|
+
Use this skill when a task asks you to inspect or change a Writer project through `ssobig-writer`. Treat the CLI, not this document, as the source of truth for command syntax. When syntax is uncertain, choose the narrowest discovery call below; these are alternatives, not a required sequence:
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
ssobig-writer <group> --help
|
|
@@ -17,22 +17,21 @@ Do not dump the complete schema when one group or leaf is enough. The root schem
|
|
|
17
17
|
|
|
18
18
|
Route by intent and read only the relevant reference:
|
|
19
19
|
|
|
20
|
-
- Installation, sign-in, diagnostics, updates, or project-local skill setup:
|
|
21
|
-
- Finding manuscript values or reading targeted Component data:
|
|
22
|
-
- Project identity, versions, Component reads, and reviewed data plans:
|
|
23
|
-
- Asset pointers, checkpoint history, restore plans, and receipts:
|
|
24
|
-
-
|
|
20
|
+
- Installation, sign-in, diagnostics, updates, or project-local skill setup: [references/install-auth.md](references/install-auth.md)
|
|
21
|
+
- Finding manuscript values or reading targeted Component data: [references/read-search.md](references/read-search.md)
|
|
22
|
+
- Project identity, versions, Component reads, and reviewed data plans: [references/projects-components.md](references/projects-components.md)
|
|
23
|
+
- Asset pointers, checkpoint history, restore plans, and receipts: [references/assets-checkpoints.md](references/assets-checkpoints.md)
|
|
24
|
+
- Investigation-board layout specs and review: [references/investigation-board.md](references/investigation-board.md)
|
|
25
|
+
- Any failure, conflict, authorization issue, or boundary response: [references/errors.md](references/errors.md)
|
|
25
26
|
|
|
26
27
|
The authoritative manuscript source is Writer Component data. Never bypass the CLI with raw SQL, generic database updates, a Supabase management client, or a service-role credential. Never place manuscript values in source code, View HTML/JavaScript, local caches, or ad-hoc JSON as a substitute for Component data.
|
|
27
28
|
|
|
28
|
-
For
|
|
29
|
+
For content changes, use `plan -> human-readable review -> apply -> authoritative read-back`. Read the target on the server and prepare a dedicated `plan-*` result; planning does not change production data. Present the exact target, before/after changes, and material effects before apply.
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
2. Create a plan with the dedicated `plan-*` command.
|
|
32
|
-
3. Present a human-readable review of the exact target and changes.
|
|
33
|
-
4. Run `apply` only after the user explicitly authorizes that mutation.
|
|
34
|
-
5. Read the authoritative state again and retain the receipt privately.
|
|
31
|
+
Apply requires an explicit request to change Writer data. Existing authorization remains valid for the same target and scope; do not ask again merely because a plan was generated. Ask when a material decision remains unresolved or the proposed mutation exceeds that scope. A request to inspect, explain, or prepare a plan does not authorize apply. A plan digest is an integrity checksum, not evidence of approval.
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
Successful apply already performs authoritative read-back and returns verified receipt fields. Inspect those fields against the intended change; use an additional targeted read when values or an uncertain outcome need inspection, and UI verification when the task concerns visible behavior. Do not automatically repeat a full validation after every successful apply. See the relevant reference for operation-specific completion criteria.
|
|
34
|
+
|
|
35
|
+
Plans, specs, patches, and receipts can contain confidential content; keep them under ignored `.ssobig-writer/` paths or another private directory and never commit them.
|
|
37
36
|
|
|
38
37
|
`E_CODE_CHANGE_REQUIRED` is a hard stop. Supported Component-management mutations are registered optional Component addition through `component plan-add`, archive/restore through `component plan-set-active`, and registered optional-field selection through `component plan-set-field`. Shared schemas, unregistered composition, reorder behavior, Renderer/View behavior, identity, and other boundaries remain outside the content CLI. Do not work around them.
|
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Asset pointers are owned by the dedicated asset Component. Use only `asset plan-upload` or `asset plan-delete`; do not patch pointers through generic Component data. Validate the selected project, asset ID, file type, and expected prior entry before review and apply.
|
|
4
4
|
|
|
5
|
-
Checkpoint commands provide history, snapshots, diffs, creation plans, and restore plans.
|
|
5
|
+
Checkpoint commands provide history, snapshots, diffs, creation plans, and restore plans. Creation and restore are production mutations; use the common review/authorization rules in [SKILL.md](../SKILL.md). Keep plan and receipt files private. Use `checkpoint diff` to make the review understandable before a restore.
|
|
6
|
+
|
|
7
|
+
Asset delete disconnects the pointer with a tombstone; it does not physically delete a committed Storage object. Upload success alone is not completion: apply must verify the manifest pointer and revision. Image rendering requires a separate UI check when requested.
|
|
8
|
+
|
|
9
|
+
Checkpoint `show` and `diff` hide manuscript values by default. Request `--include-data` or `--include-values` only when needed for the review. Checkpoint restore read-back verifies restored content and checkpoint metadata; it does not restore arbitrary incompatible schemas/composition.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# Errors and boundaries
|
|
2
2
|
|
|
3
|
-
- `E_CLI_UPDATE_REQUIRED`: the original operation was not sent. Follow the returned npm `installCommand`; ZIP installers are unsupported.
|
|
3
|
+
- `E_CLI_UPDATE_REQUIRED`: the original operation was not sent. Follow the returned npm `installCommand`; ZIP installers are unsupported. Use its returned `skillCommands.status`, `skillCommands.install`, and `skillCommands.update` as applicable; follow [the update lifecycle](install-auth.md) without repeating a completed version check.
|
|
4
4
|
- `E_CODE_CHANGE_REQUIRED`: hard boundary; do not use SQL or another client. Separate a code or migration task.
|
|
5
|
+
- `E_AMBIGUOUS_SAVE`: the write may have committed. Do not reapply the same plan or automatically retry. Read the affected Component, asset manifest, project status, or checkpoint history on the server and compare the observed result with the plan/receipt before deciding the next action. A timeout is not proof of failure.
|
|
6
|
+
- `E_MATCH_STALE`: refresh the index/search and create a new plan from the current match.
|
|
7
|
+
- `E_DAEMON_REQUEST_TIMEOUT`: a read-only request timed out; retry a read if useful. Mutation timeouts become `E_AMBIGUOUS_SAVE` and follow the rule above.
|
|
5
8
|
- `E_CONFLICT`: authoritative data changed after the plan. Read again and create a new plan.
|
|
6
9
|
- `E_AUTH_REQUIRED`, `E_AUTH_EXPIRED`, `E_AUTHORIZATION`: use the supported login flow or ask an authorized staff member; never handle tokens directly.
|
|
7
10
|
- `E_SKILL_CONFLICT`: an unmanaged or user-modified skill file is present. Preserve it and inspect status/diff; do not overwrite it.
|
|
@@ -4,9 +4,9 @@ Use targeted help for `version`, `doctor`, `auth`, and `skills`. Install the npm
|
|
|
4
4
|
|
|
5
5
|
Check `version --check` before long work. From CLI 0.2.0 onward, outdated server-access commands stop with `E_CLI_UPDATE_REQUIRED`; local help, schemas, diagnostics, logout, daemon cleanup, and skill management remain available. Registry outages fail open unless a cached newer version is already known.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
When an update is required, resume at the first unfinished step:
|
|
8
8
|
|
|
9
|
-
1.
|
|
9
|
+
1. If no current update result is available, run `ssobig-writer version --check`. Otherwise reuse the sanitized remediation from `E_CLI_UPDATE_REQUIRED`, `version`, or `doctor`; do not repeat the check.
|
|
10
10
|
2. Update with `npm install --global @ssobig/writer-cli@latest`. npm is the only supported distribution channel; do not look for or use ZIP installers.
|
|
11
11
|
3. Run `ssobig-writer skills status --target <workspace>` from the updated CLI.
|
|
12
12
|
4. If the skill is missing, run `ssobig-writer skills install --target <workspace>`. If it reports `update-available`, run `ssobig-writer skills update --target <workspace>`. Do nothing when it reports `current`.
|
|
@@ -16,4 +16,4 @@ Updating the global CLI never silently changes a workspace. Keep this explicit s
|
|
|
16
16
|
|
|
17
17
|
Before uninstalling the CLI, preview project-local skill removal with `ssobig-writer skills remove --target <workspace> --managed-only --dry-run`. If the preview owns only the expected managed files, run it again without `--dry-run`, then uninstall the npm package. Never delete an entire `.agents/skills` or `.claude/skills` directory.
|
|
18
18
|
|
|
19
|
-
Authentication uses
|
|
19
|
+
Authentication uses Google sign-in for Writer members. Project owner/editor/viewer permissions determine access; administrative operations retain their separate authorization checks. Never request, print, persist, or transmit access tokens. Use `doctor` for sanitized installation, credential-store, network, update, and daemon-version diagnostics.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Investigation-board plans
|
|
2
|
+
|
|
3
|
+
Read [layout-spec.md](layout-spec.md) before authoring a spec; it contains the complete contract and a generic example. Read the board and any referenced characters/clues through `component get`, and use their stable IDs. If the board is missing or archived, inspect `component list`: a registered addable/restorable board uses its dedicated reviewed `plan-add`/`plan-set-active` operation when within the requested scope. Unregistered composition remains a code boundary.
|
|
4
|
+
|
|
5
|
+
Write a semantic spec under `.ssobig-writer/`, then use `component plan-layout-investigation-board --project <slug> --spec <private-spec-file> --out <private-plan-file>`. The command generates a local plan and does not save Writer data. It replaces the board's complete `viewport`, `nodes`, and `edges`; disclose this effect in the review.
|
|
6
|
+
|
|
7
|
+
Use the center cluster for the main conflict or anchors, primary nodes for visual emphasis, and short relationship labels. Aim for at most 24 nodes and seven primary edges in an overview; these are readability targets, not schema limits. Inspect `layout.review` for crossings, card intersections, long edges, orphans, and quality warnings. Revise semantic grouping/order when the review exposes a problem; do not hand-edit generated coordinates.
|
|
8
|
+
|
|
9
|
+
Use the common authorization rules in [SKILL.md](../SKILL.md). After successful apply, inspect the verified receipt. Read the board when you need to compare values, and inspect the rendered board when visual readability is part of the request; a quality score alone does not prove the rendered result. Use `validate` to diagnose unresolved composition/schema issues, not as an automatic duplicate of successful apply validation.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Investigation board semantic layout spec
|
|
2
|
+
|
|
3
|
+
Use schema version `ssobig-investigation-board-layout-v1`. This file describes meaning and reading order; `component plan-layout-investigation-board` converts it into strict `ssobig.investigation-board@1` data with deterministic coordinates, sizes, and colors.
|
|
4
|
+
|
|
5
|
+
## Top-level contract
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"schemaVersion": "ssobig-investigation-board-layout-v1",
|
|
10
|
+
"layout": "hub-clusters",
|
|
11
|
+
"clusters": [],
|
|
12
|
+
"nodes": [],
|
|
13
|
+
"edges": []
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `clusters`: 2–5 entries. Each uses a unique `band`; `center` is required.
|
|
18
|
+
- `nodes`: 2–38 entries. Every cluster must contain at least one node.
|
|
19
|
+
- `edges`: 1–120 entries.
|
|
20
|
+
- IDs use letters, numbers, `.`, `_`, `:`, or `-`, begin with a letter or number, and are at most 128 characters.
|
|
21
|
+
- Unknown fields are rejected. Do not include generated board fields such as coordinates, sizes, or colors.
|
|
22
|
+
|
|
23
|
+
## Clusters
|
|
24
|
+
|
|
25
|
+
Each cluster has:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{ "id": "core", "label": "핵심 갈등", "band": "center" }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Allowed bands are `top`, `left`, `center`, `right`, and `bottom`. Use one cluster per band. Capacity is 8 nodes in `top`, `left`, `right`, and `bottom`, and 6 nodes in `center`.
|
|
32
|
+
|
|
33
|
+
A practical overview uses:
|
|
34
|
+
|
|
35
|
+
- `center`: main characters, central 사건, or the primary contradiction;
|
|
36
|
+
- `top`: past event, premise, or upstream cause;
|
|
37
|
+
- `left` and `right`: competing statements, factions, or evidence groups;
|
|
38
|
+
- `bottom`: consequences, unresolved questions, or the current hypothesis.
|
|
39
|
+
|
|
40
|
+
## Nodes
|
|
41
|
+
|
|
42
|
+
All nodes require `id`, `type`, `clusterId`, `order`, `importance`, and `semantic`.
|
|
43
|
+
|
|
44
|
+
Reference node:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "character-a",
|
|
49
|
+
"type": "reference",
|
|
50
|
+
"clusterId": "core",
|
|
51
|
+
"order": 0,
|
|
52
|
+
"importance": "primary",
|
|
53
|
+
"semantic": "character",
|
|
54
|
+
"referenceType": "character",
|
|
55
|
+
"referenceId": "stable-character-key"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Sticky node:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"id": "contradiction-a",
|
|
64
|
+
"type": "sticky",
|
|
65
|
+
"clusterId": "statements",
|
|
66
|
+
"order": 0,
|
|
67
|
+
"importance": "secondary",
|
|
68
|
+
"semantic": "question",
|
|
69
|
+
"title": "시간 모순",
|
|
70
|
+
"text": "두 진술의 사건 시간이 일치하지 않는다."
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
|
|
76
|
+
- `order` is a unique integer from 0 to 999 inside a cluster. It controls deterministic grid order.
|
|
77
|
+
- `importance` is `primary` or `secondary` and controls card size.
|
|
78
|
+
- `semantic` is `character`, `evidence`, `question`, `allegation`, `fact`, or `context`; it controls the shared palette.
|
|
79
|
+
- A character reference must use `semantic: "character"`. A clue reference cannot use `character`. A sticky cannot use `character`.
|
|
80
|
+
- `referenceType` is `character` or `clue`. `referenceId` must already exist in the authoritative work. A clue reference uses the canonical clue UUID `id` only.
|
|
81
|
+
- Sticky `title` is at most 120 characters and `text` is at most 5,000 characters.
|
|
82
|
+
|
|
83
|
+
## Edges
|
|
84
|
+
|
|
85
|
+
Each edge has:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"id": "relation-a",
|
|
90
|
+
"fromNodeId": "character-a",
|
|
91
|
+
"toNodeId": "contradiction-a",
|
|
92
|
+
"order": 0,
|
|
93
|
+
"direction": "forward",
|
|
94
|
+
"label": "진술이 모순됨",
|
|
95
|
+
"kind": "evidence"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `order` is globally unique from 0 to 999.
|
|
100
|
+
- `direction` is `none`, `forward`, or `both`.
|
|
101
|
+
- `label` is non-empty and at most 200 characters.
|
|
102
|
+
- `kind` is `primary`, `causal`, `claim`, `evidence`, or `context`; it controls line color and stored order.
|
|
103
|
+
- Use `primary` only for the relationships someone must understand at first glance. Keep labels short and concrete.
|
|
104
|
+
|
|
105
|
+
## Complete generic example
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"schemaVersion": "ssobig-investigation-board-layout-v1",
|
|
110
|
+
"layout": "hub-clusters",
|
|
111
|
+
"clusters": [
|
|
112
|
+
{ "id": "core", "label": "핵심 인물", "band": "center" },
|
|
113
|
+
{ "id": "past", "label": "과거 사건", "band": "top" },
|
|
114
|
+
{ "id": "evidence", "label": "증거와 진술", "band": "right" }
|
|
115
|
+
],
|
|
116
|
+
"nodes": [
|
|
117
|
+
{ "id": "person-a", "type": "reference", "clusterId": "core", "order": 0, "importance": "primary", "semantic": "character", "referenceType": "character", "referenceId": "stable-character-a" },
|
|
118
|
+
{ "id": "person-b", "type": "reference", "clusterId": "core", "order": 1, "importance": "primary", "semantic": "character", "referenceType": "character", "referenceId": "stable-character-b" },
|
|
119
|
+
{ "id": "past-event", "type": "sticky", "clusterId": "past", "order": 0, "importance": "primary", "semantic": "fact", "title": "과거 사건", "text": "현재 갈등의 원인이 된 사건" },
|
|
120
|
+
{ "id": "open-question", "type": "sticky", "clusterId": "evidence", "order": 0, "importance": "secondary", "semantic": "question", "title": "확인할 점", "text": "두 진술의 시간이 왜 다른가?" }
|
|
121
|
+
],
|
|
122
|
+
"edges": [
|
|
123
|
+
{ "id": "main-conflict", "fromNodeId": "person-a", "toNodeId": "person-b", "order": 0, "direction": "both", "label": "대립", "kind": "primary" },
|
|
124
|
+
{ "id": "past-cause", "fromNodeId": "past-event", "toNodeId": "person-a", "order": 1, "direction": "forward", "label": "동기", "kind": "causal" },
|
|
125
|
+
{ "id": "question-target", "fromNodeId": "open-question", "toNodeId": "person-b", "order": 2, "direction": "forward", "label": "진술 확인", "kind": "evidence" }
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Replace the generic stable IDs only after authoritative CLI reads. The algorithm version `hub-clusters-v1` always generates the same board data and review for the same normalized spec and reference catalog.
|
|
@@ -4,4 +4,10 @@ Project commands expose identity, versions, and reviewed project-level plans. Co
|
|
|
4
4
|
|
|
5
5
|
Generic Component patching changes only an existing Instance's `data`. Check `component list` for `addableTemplates` and `archivedComponents`. Use `component plan-add` for a never-created registered optional Component, `component plan-set-active` for its data-preserving archive/restore toggle, and `component plan-set-field` for a registered optional field shown in the Component screen. These commands cannot edit arbitrary composition, metadata, View bindings, templates, schemas, project identity, or storage paths. If the CLI returns `E_CODE_CHANGE_REQUIRED`, stop and report that a separate code or migration review is required.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Use the common review/authorization rules in [SKILL.md](../SKILL.md). For Component patches, successful apply verifies planned data, saved revision, Set revision, and the automatic checkpoint. Inspect the receipt; read the Component again if you need actual values. A stored-data receipt does not verify the rendered UI.
|
|
8
|
+
|
|
9
|
+
Project creation/import, project archive/restore, and Component addition/toggling have different read-back targets. Inspect the resulting identity/status/composition rather than always issuing a Component read. An import receipt can contain `pendingAssets`; import success alone does not mean those assets have been uploaded.
|
|
10
|
+
|
|
11
|
+
For `component plan-patch`, the RFC 6902 root is Instance `data`; supported operations are `test`, `add`, `replace`, and `remove`. Use authoritative stable IDs and test prior values. Keep patch/plan/receipt files under `.ssobig-writer/`. For a text match, `component plan-replace` creates the guarded patch from a server reread. Use dedicated commands for registered optional fields and timeline migration.
|
|
12
|
+
|
|
13
|
+
For investigation-board layouts, read [investigation-board.md](investigation-board.md).
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Read and search
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
When the location is unknown, use a scoped `search`; when the project/Instance is known, read it directly. `read` without a Component returns an overview, so a known target does not require a corpus dump or a search first. Inspect leaf help/schema only when syntax is uncertain.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| Need | Command and evidence |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Locate a value or inspect indexed structure | `search` / `read`: local index; `authoritative: false`, `requiresServerReadBeforeWrite: true`. |
|
|
8
|
+
| Refresh the index now | `--fresh`: syncs the corpus first; output remains an indexed snapshot. |
|
|
9
|
+
| Refresh an index known to be stale | `--require-fresh`: syncs when missing, invalidated, or not marked fresh; it is not a server read on every call. |
|
|
10
|
+
| Read a Component on the server | `component get --project <slug> --instance <id>` |
|
|
11
|
+
| Read a search match on the server | `agent get --match <match-id>`; scalar output is bounded. |
|
|
12
|
+
|
|
13
|
+
Prefer exact project, Component, path, and value filters. Plan commands reread server state and check revisions/hashes; cached search results never authorize a write. `E_MATCH_STALE` requires a refreshed search and a new plan. After apply the index is invalidated; plain `read` is not proof of the saved value.
|
|
14
|
+
|
|
15
|
+
Search/read output may contain confidential manuscript content. Summarize only what the task needs.
|