rootr-cli 0.9.0 → 0.13.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.
- package/README.md +230 -5
- package/bin/rootr.js +864 -3
- package/lib/client.js +341 -5
- package/lib/config.js +5 -0
- package/lib/mcp-tools/databases.js +334 -4
- package/lib/mcp-tools/documents.js +10 -4
- package/lib/mcp-tools/feedback.js +97 -0
- package/lib/mcp-tools/forms.js +62 -7
- package/lib/mcp-tools/misc.js +84 -1
- package/lib/mcp-tools/presentations.js +5 -1
- package/lib/mcp-tools/public.js +148 -0
- package/lib/mcp-tools/signed.js +495 -0
- package/lib/mcp-tools/workspaces.js +117 -13
- package/lib/mcp.js +47 -4
- package/package.json +1 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jsonResult,
|
|
5
|
+
textResult,
|
|
6
|
+
errorResult,
|
|
7
|
+
makeClient,
|
|
8
|
+
requireWorkspace,
|
|
9
|
+
resolveNodeId,
|
|
10
|
+
resolveOptionalNodeId,
|
|
11
|
+
NODE_TARGET_HINT,
|
|
12
|
+
} from './shared.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SIGN-001 — 서명문서 · 연구노트.
|
|
16
|
+
*
|
|
17
|
+
* 마크다운 문서인데 **올리는 순간 본문이 얼고**, 결재라인에 적힌 사람들이 차례로
|
|
18
|
+
* 서명하면 서명마다 시점확인(RFC 3161 TSA)과 워크스페이스 원장 해시 체이닝이
|
|
19
|
+
* 붙는다. 국가R&D 전자연구노트 지침이 요구하는 ①전자서명 ②시점확인 ③위변조확인이
|
|
20
|
+
* 그대로 이 셋이다.
|
|
21
|
+
*
|
|
22
|
+
* 에이전트가 대신 눌러 줄 수 없는 자리가 하나 있다: **서명**. 확인 코드가 사람
|
|
23
|
+
* 메일로만 가고, 그게 "본인이 눌렀다"의 유일한 증거라서 그렇다. 그래서 흐름은
|
|
24
|
+
* 항상 rootr_request_signing_code → (사람이 메일에서 코드를 읽어 알려 준다) →
|
|
25
|
+
* rootr_sign_document 다. 코드를 지어내지 말고 사람에게 물어야 한다.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const APPROVAL_STEP = z.object({
|
|
29
|
+
label: z.string().optional().describe('단계 이름, 예: "기재자" / "점검자" / "부서장"'),
|
|
30
|
+
rule: z
|
|
31
|
+
.enum(['ALL', 'ANY', 'N_OF_M'])
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('ALL=이 단계 전원 서명(기본), ANY=아무나 한 명, N_OF_M=requiredCount 명'),
|
|
34
|
+
requiredCount: z.number().int().min(1).optional().describe('rule이 N_OF_M일 때 필요한 인원 수'),
|
|
35
|
+
members: z
|
|
36
|
+
.array(
|
|
37
|
+
z.object({
|
|
38
|
+
userId: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('워크스페이스 멤버의 userId. 밖의 사람이면 대신 email을 쓴다'),
|
|
42
|
+
email: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe(
|
|
46
|
+
'워크스페이스 밖 결재자의 메일 주소. 그 사람에게 서명 링크가 메일로 가고, ' +
|
|
47
|
+
'계정 없이 서명한다. userId와 둘 중 하나만 쓴다',
|
|
48
|
+
),
|
|
49
|
+
displayName: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('메일 결재자를 화면에 뭐라고 부를지. 생략하면 메일 주소를 그대로 쓴다'),
|
|
53
|
+
role: z
|
|
54
|
+
.enum(['AUTHOR', 'REVIEWER', 'APPROVER'])
|
|
55
|
+
.optional()
|
|
56
|
+
.describe('AUTHOR=기재자, REVIEWER=점검자, APPROVER=결재자. 생략하면 자리로 정해진다'),
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
.min(1),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const CODE_HINT =
|
|
63
|
+
'메일로 받은 6자리 확인 코드. 먼저 rootr_request_signing_code 를 부르면 사용자 메일로 가고, ' +
|
|
64
|
+
'사용자가 알려 준 값을 그대로 넣는다. 절대 지어내지 말 것 — 틀리면 403이고 5회 틀리면 코드가 죽는다.';
|
|
65
|
+
|
|
66
|
+
export function registerSignedDocTools(server) {
|
|
67
|
+
server.registerTool(
|
|
68
|
+
'rootr_create_signed_document',
|
|
69
|
+
{
|
|
70
|
+
title: 'Create a Rootr signed document',
|
|
71
|
+
description:
|
|
72
|
+
'Create a SIGNED document (서명문서 · 전자결재 · 국가R&D 전자연구노트) in a Rootr (루터) workspace. ' +
|
|
73
|
+
'A markdown document that FREEZES on submit: after that the body, attachments and approval line ' +
|
|
74
|
+
'cannot be edited, every signature carries an RFC 3161 timestamp, and the whole workspace keeps a ' +
|
|
75
|
+
'hash-chained ledger so tampering is detectable. Signed documents cannot be deleted (30-year ' +
|
|
76
|
+
'retention). Use this instead of a plain DOCUMENT whenever the content must be provable — 결재/승인 ' +
|
|
77
|
+
'문서, 연구노트, 점검 기록, 회의 확인서. ' +
|
|
78
|
+
'Flow: create (body + approval line) → edit the body freely with rootr_write/rootr_append → ' +
|
|
79
|
+
'rootr_request_signing_code → ask the human for the 6-digit code → rootr_submit_signed_document ' +
|
|
80
|
+
'(freezes + first signature) → each remaining signer does request-code + rootr_sign_document.',
|
|
81
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
82
|
+
inputSchema: {
|
|
83
|
+
name: z.string().optional().describe('문서 이름, 예: "2026-08-25 실험 기록"'),
|
|
84
|
+
path: z
|
|
85
|
+
.string()
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('트리 경로로 만들 때, 예: "/연구노트/2026-08-25 실험기록" (parentId 대신)'),
|
|
88
|
+
parentId: z.string().optional().describe(`부모 폴더 id${NODE_TARGET_HINT}`),
|
|
89
|
+
createParents: z.boolean().optional().describe('path를 쓸 때 없는 상위 폴더까지 만든다'),
|
|
90
|
+
icon: z.string().optional().describe('이모지 아이콘'),
|
|
91
|
+
content: z.string().optional().describe('본문 마크다운. 올리기 전까지는 얼마든지 고칠 수 있다'),
|
|
92
|
+
mode: z
|
|
93
|
+
.enum(['APPROVAL', 'RESEARCH_NOTE'])
|
|
94
|
+
.optional()
|
|
95
|
+
.describe('APPROVAL=전자결재(기본), RESEARCH_NOTE=연구노트'),
|
|
96
|
+
reviewerUserId: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(
|
|
100
|
+
'연구노트 프리셋용 점검자(연구책임자) userId. approvalLine 없이 이것만 주면 ' +
|
|
101
|
+
'"기재자(만든 사람) → 점검자" 두 단계가 자동으로 짜인다',
|
|
102
|
+
),
|
|
103
|
+
noteMeta: z
|
|
104
|
+
.record(z.any())
|
|
105
|
+
.optional()
|
|
106
|
+
.describe('연구노트 표지 정보: 과제번호·과제명·연구책임자·기관 등'),
|
|
107
|
+
approvalLine: z
|
|
108
|
+
.array(APPROVAL_STEP)
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('결재라인. 배열 순서가 곧 서명 순서다'),
|
|
111
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
async ({ parentId, workspace, ...rest }) => {
|
|
115
|
+
try {
|
|
116
|
+
const ws = requireWorkspace(workspace);
|
|
117
|
+
const client = makeClient(ws);
|
|
118
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
119
|
+
return jsonResult(await client.createSignedDoc(ws, { ...rest, parentId }));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return errorResult(err);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.registerTool(
|
|
127
|
+
'rootr_read_signed_document',
|
|
128
|
+
{
|
|
129
|
+
title: 'Read a Rootr signed document state',
|
|
130
|
+
description:
|
|
131
|
+
'Read a SIGNED document\'s signing state: mode, status (DRAFT/IN_PROGRESS/COMPLETED/REJECTED/' +
|
|
132
|
+
'SUPERSEDED), whether it is locked, the approval line with each step\'s progress, every signature so far ' +
|
|
133
|
+
'(signer, time, TSA timestamp), whether the body still matches the frozen hash, and whether YOU can ' +
|
|
134
|
+
'sign right now (canSign). Read the body itself with rootr_read.',
|
|
135
|
+
annotations: { readOnlyHint: true },
|
|
136
|
+
inputSchema: {
|
|
137
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
138
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
async ({ nodeId, workspace }) => {
|
|
142
|
+
try {
|
|
143
|
+
const client = makeClient(workspace);
|
|
144
|
+
return jsonResult(await client.getSignedDoc(await resolveNodeId(client, nodeId)));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return errorResult(err);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
server.registerTool(
|
|
152
|
+
'rootr_set_approval_line',
|
|
153
|
+
{
|
|
154
|
+
title: 'Set a signed document approval line',
|
|
155
|
+
description:
|
|
156
|
+
'Replace the approval line (결재라인) of a SIGNED document. The array order IS the signing order; ' +
|
|
157
|
+
'each step can require everyone (ALL), anyone (ANY) or N of M. Only possible BEFORE submit — once ' +
|
|
158
|
+
'the document is submitted the line is frozen with the body (409 SIGNED_DOC_LOCKED); reject it first ' +
|
|
159
|
+
'to reopen. Each member is EITHER a workspace member (userId) OR an outside person (email) — ' +
|
|
160
|
+
'an email signer gets a signing link by mail and signs without an account. Never both on one member.',
|
|
161
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
162
|
+
inputSchema: {
|
|
163
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
164
|
+
steps: z.array(APPROVAL_STEP).min(1).describe('단계 배열. 순서가 곧 서명 순서다'),
|
|
165
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
async ({ nodeId, steps, workspace }) => {
|
|
169
|
+
try {
|
|
170
|
+
const client = makeClient(workspace);
|
|
171
|
+
return jsonResult(await client.setApprovalLine(await resolveNodeId(client, nodeId), steps));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return errorResult(err);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
'rootr_apply_research_note_preset',
|
|
180
|
+
{
|
|
181
|
+
title: 'Turn a signed document into a research note',
|
|
182
|
+
description:
|
|
183
|
+
'Switch a SIGNED document to RESEARCH_NOTE mode and build the two-step line the Korean national R&D ' +
|
|
184
|
+
'guideline requires: 기재자(the caller) → 점검자(the reviewer/연구책임자). Optionally stores the cover ' +
|
|
185
|
+
'metadata (과제번호·과제명·기관). Use this instead of hand-building the approval line for 연구노트.',
|
|
186
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
187
|
+
inputSchema: {
|
|
188
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
189
|
+
reviewerUserId: z.string().describe('점검자(연구책임자)의 userId'),
|
|
190
|
+
noteMeta: z.record(z.any()).optional().describe('표지 정보: 과제번호·과제명·연구책임자·기관 등'),
|
|
191
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
async ({ nodeId, reviewerUserId, noteMeta, workspace }) => {
|
|
195
|
+
try {
|
|
196
|
+
const client = makeClient(workspace);
|
|
197
|
+
return jsonResult(
|
|
198
|
+
await client.applyResearchNotePreset(await resolveNodeId(client, nodeId), {
|
|
199
|
+
reviewerUserId,
|
|
200
|
+
noteMeta,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return errorResult(err);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
server.registerTool(
|
|
210
|
+
'rootr_request_signing_code',
|
|
211
|
+
{
|
|
212
|
+
title: 'Email a signing confirmation code',
|
|
213
|
+
description:
|
|
214
|
+
'Send the 6-digit signing confirmation code to the signer\'s own email. REQUIRED before ' +
|
|
215
|
+
'rootr_submit_signed_document / rootr_sign_document / rootr_reject_signed_document / ' +
|
|
216
|
+
'rootr_bulk_sign_documents — the code is the only evidence that a HUMAN pressed sign, so you cannot ' +
|
|
217
|
+
'read it and must ask the user for it. Valid 10 minutes, 5 wrong tries kill it, and re-requesting ' +
|
|
218
|
+
'within 30 seconds is refused. Pass `bulk: true` (with no nodeId) for one code that covers a bulk sign.',
|
|
219
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
220
|
+
inputSchema: {
|
|
221
|
+
nodeId: z
|
|
222
|
+
.string()
|
|
223
|
+
.optional()
|
|
224
|
+
.describe(`서명문서 노드 id${NODE_TARGET_HINT}. bulk가 true면 생략한다`),
|
|
225
|
+
bulk: z.boolean().optional().describe('묶음 서명용 코드를 받을 때 true'),
|
|
226
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
async ({ nodeId, bulk, workspace }) => {
|
|
230
|
+
try {
|
|
231
|
+
if (bulk) {
|
|
232
|
+
const ws = requireWorkspace(workspace);
|
|
233
|
+
const client = makeClient(ws);
|
|
234
|
+
const out = await client.requestBulkSigningCode(ws);
|
|
235
|
+
return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
|
|
236
|
+
}
|
|
237
|
+
const client = makeClient(workspace);
|
|
238
|
+
if (!nodeId) throw new Error('nodeId 또는 bulk: true 중 하나가 필요합니다.');
|
|
239
|
+
const out = await client.requestSigningCode(await resolveNodeId(client, nodeId));
|
|
240
|
+
return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return errorResult(err);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.registerTool(
|
|
248
|
+
'rootr_submit_signed_document',
|
|
249
|
+
{
|
|
250
|
+
title: 'Submit (freeze + first-sign) a signed document',
|
|
251
|
+
description:
|
|
252
|
+
'Submit a SIGNED document: the body and attachments FREEZE (their SHA-256 is recorded) and the ' +
|
|
253
|
+
'first step\'s signers are notified. IRREVERSIBLE except by rejection — after this the only way ' +
|
|
254
|
+
'to change the content is a revision (rootr_revise_signed_document). ' +
|
|
255
|
+
'Submitting and signing are DIFFERENT acts: submitting only needs write access. `code` is required ' +
|
|
256
|
+
'ONLY when the submitter is themselves a signer in step 1, because then their signature is stamped ' +
|
|
257
|
+
'at the same time — get it with rootr_request_signing_code and ask the human for it. If step 1 has ' +
|
|
258
|
+
'no account members (e.g. only email signers), omit `code` entirely. When in doubt omit it: the ' +
|
|
259
|
+
'server answers REAUTH_REQUIRED if a code was in fact needed.',
|
|
260
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
261
|
+
inputSchema: {
|
|
262
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
263
|
+
code: z
|
|
264
|
+
.string()
|
|
265
|
+
.optional()
|
|
266
|
+
.describe(`올리는 사람이 1단계 서명자일 때만 필요합니다. ${CODE_HINT}`),
|
|
267
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
async ({ nodeId, code, workspace }) => {
|
|
271
|
+
try {
|
|
272
|
+
const client = makeClient(workspace);
|
|
273
|
+
const id = await resolveNodeId(client, nodeId);
|
|
274
|
+
return jsonResult(await client.submitSignedDoc(id, code ? { code } : {}));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return errorResult(err);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
server.registerTool(
|
|
282
|
+
'rootr_sign_document',
|
|
283
|
+
{
|
|
284
|
+
title: 'Sign a Rootr signed document',
|
|
285
|
+
description:
|
|
286
|
+
'Sign a SIGNED document as the current step\'s signer. Records an Ed25519 signature over the frozen ' +
|
|
287
|
+
'content hash, requests an RFC 3161 timestamp, and links the signature into the workspace hash ledger. ' +
|
|
288
|
+
'Only works when it is your turn (check canSign with rootr_read_signed_document). Needs the emailed ' +
|
|
289
|
+
'code from rootr_request_signing_code — ask the human for it, never invent it.',
|
|
290
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
291
|
+
inputSchema: {
|
|
292
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
293
|
+
code: z.string().describe(CODE_HINT),
|
|
294
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
async ({ nodeId, code, workspace }) => {
|
|
298
|
+
try {
|
|
299
|
+
const client = makeClient(workspace);
|
|
300
|
+
return jsonResult(await client.signSignedDoc(await resolveNodeId(client, nodeId), { code }));
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return errorResult(err);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
server.registerTool(
|
|
308
|
+
'rootr_reject_signed_document',
|
|
309
|
+
{
|
|
310
|
+
title: 'Reject a signed document',
|
|
311
|
+
description:
|
|
312
|
+
'Reject a SIGNED document with a reason. The document unlocks so the author can fix and resubmit; ' +
|
|
313
|
+
'signatures already collected are recorded as part of the history and the rejection itself is ' +
|
|
314
|
+
'notified to the author. The reason is required — "반려됨"만 남으면 고칠 수가 없다.',
|
|
315
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
316
|
+
inputSchema: {
|
|
317
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
318
|
+
comment: z.string().describe('반려 사유 (필수)'),
|
|
319
|
+
code: z.string().optional().describe(CODE_HINT),
|
|
320
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
async ({ nodeId, comment, code, workspace }) => {
|
|
324
|
+
try {
|
|
325
|
+
const client = makeClient(workspace);
|
|
326
|
+
return jsonResult(
|
|
327
|
+
await client.rejectSignedDoc(await resolveNodeId(client, nodeId), { comment, code }),
|
|
328
|
+
);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return errorResult(err);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
server.registerTool(
|
|
336
|
+
'rootr_reopen_signed_document',
|
|
337
|
+
{
|
|
338
|
+
title: 'Reopen an unsigned signed-document draft',
|
|
339
|
+
description:
|
|
340
|
+
'Unlock a SIGNED document so the body and the approval line can be edited again. Works when the ' +
|
|
341
|
+
'document was sent back (REJECTED), or when it is still in progress and **nobody has signed yet** — ' +
|
|
342
|
+
'the escape hatch for "I submitted it to the wrong address". Once any signature exists this refuses: ' +
|
|
343
|
+
'make a revision instead (rootr_revise_signed_document).',
|
|
344
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
345
|
+
inputSchema: {
|
|
346
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
347
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
async ({ nodeId, workspace }) => {
|
|
351
|
+
try {
|
|
352
|
+
const client = makeClient(workspace);
|
|
353
|
+
return jsonResult(await client.reopenSignedDoc(await resolveNodeId(client, nodeId)));
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return errorResult(err);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
server.registerTool(
|
|
361
|
+
'rootr_revise_signed_document',
|
|
362
|
+
{
|
|
363
|
+
title: 'Create a revision of a signed document',
|
|
364
|
+
description:
|
|
365
|
+
'Create a NEW revision of an already-signed document. Signed documents are never edited in place — ' +
|
|
366
|
+
'the guideline requires that both the original and the correction stay visible — so this copies the ' +
|
|
367
|
+
'body into a fresh SIGNED node (revisionNo + 1), marks the original SUPERSEDED, and returns the new ' +
|
|
368
|
+
'node id. The reason (note) is recorded on the revision.',
|
|
369
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
370
|
+
inputSchema: {
|
|
371
|
+
nodeId: z.string().describe(`원본 서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
372
|
+
note: z.string().describe('무엇을 왜 고치는지 (필수, 개정본에 남는다)'),
|
|
373
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
async ({ nodeId, note, workspace }) => {
|
|
377
|
+
try {
|
|
378
|
+
const client = makeClient(workspace);
|
|
379
|
+
return jsonResult(await client.reviseSignedDoc(await resolveNodeId(client, nodeId), note));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return errorResult(err);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
server.registerTool(
|
|
387
|
+
'rootr_verify_signed_document',
|
|
388
|
+
{
|
|
389
|
+
title: 'Verify a signed document (tamper check)',
|
|
390
|
+
description:
|
|
391
|
+
'Verify a SIGNED document end to end: every Ed25519 signature is re-checked against its payload, ' +
|
|
392
|
+
'every hash-chain link is recomputed, the current body is hashed and compared with the frozen hash, ' +
|
|
393
|
+
'and the workspace ledger is replayed. Returns contentIntact / allSignaturesValid / allTimestamped / ' +
|
|
394
|
+
'ledgerIntact plus per-signature detail. This is the answer to "이 문서가 위조되지 않았음을 증명해 봐".',
|
|
395
|
+
annotations: { readOnlyHint: true },
|
|
396
|
+
inputSchema: {
|
|
397
|
+
nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
|
|
398
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
async ({ nodeId, workspace }) => {
|
|
402
|
+
try {
|
|
403
|
+
const client = makeClient(workspace);
|
|
404
|
+
return jsonResult(await client.verifySignedDoc(await resolveNodeId(client, nodeId)));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return errorResult(err);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
server.registerTool(
|
|
412
|
+
'rootr_list_signed_documents',
|
|
413
|
+
{
|
|
414
|
+
title: 'List Rootr signed documents',
|
|
415
|
+
description:
|
|
416
|
+
'List the SIGNED documents of a workspace with their status and who each one is waiting on. ' +
|
|
417
|
+
'Set pendingMine: true for "내 차례인 것만" — the list a 연구책임자 opens to see this week\'s ' +
|
|
418
|
+
'unchecked notes before bulk-signing them.',
|
|
419
|
+
annotations: { readOnlyHint: true },
|
|
420
|
+
inputSchema: {
|
|
421
|
+
status: z
|
|
422
|
+
.enum(['DRAFT', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'SUPERSEDED', 'VOIDED'])
|
|
423
|
+
.optional(),
|
|
424
|
+
mode: z.enum(['APPROVAL', 'RESEARCH_NOTE']).optional(),
|
|
425
|
+
pendingMine: z.boolean().optional().describe('내 서명을 기다리는 것만'),
|
|
426
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
async ({ status, mode, pendingMine, workspace }) => {
|
|
430
|
+
try {
|
|
431
|
+
const ws = requireWorkspace(workspace);
|
|
432
|
+
const client = makeClient(ws);
|
|
433
|
+
return jsonResult(await client.listSignedDocs(ws, { status, mode, pendingMine }));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
return errorResult(err);
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
server.registerTool(
|
|
441
|
+
'rootr_bulk_sign_documents',
|
|
442
|
+
{
|
|
443
|
+
title: 'Submit and sign many Rootr documents at once',
|
|
444
|
+
description:
|
|
445
|
+
'Process up to 100 SIGNED documents in one call with ONE emailed code (get it with ' +
|
|
446
|
+
'rootr_request_signing_code + bulk: true). ' +
|
|
447
|
+
'**DRAFT documents are submitted too** — you do NOT need a separate per-document code to submit ' +
|
|
448
|
+
'them first. A draft is frozen and, if you are a step-1 signer, stamped at the same time; a document ' +
|
|
449
|
+
'already IN_PROGRESS is simply signed. Each result carries `did`: "submitted", "submitted+signed" ' +
|
|
450
|
+
'or "signed". Documents where it is not your turn are skipped and reported, not failed. ' +
|
|
451
|
+
'This is what makes 연구노트 usable in practice — a 연구책임자 clearing "이번 주 미점검 12건" ' +
|
|
452
|
+
'without opening each one. Submitting FREEZES the body irreversibly, so pass only the ids you mean.',
|
|
453
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
454
|
+
inputSchema: {
|
|
455
|
+
nodeIds: z.array(z.string()).min(1).max(100).describe('서명할 노드 id 목록 (경로도 됩니다)'),
|
|
456
|
+
code: z.string().describe(CODE_HINT + ' (bulk: true 로 받은 코드)'),
|
|
457
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
async ({ nodeIds, code, workspace }) => {
|
|
461
|
+
try {
|
|
462
|
+
const ws = requireWorkspace(workspace);
|
|
463
|
+
const client = makeClient(ws);
|
|
464
|
+
const ids = [];
|
|
465
|
+
for (const t of nodeIds) ids.push(await resolveNodeId(client, t));
|
|
466
|
+
return jsonResult(await client.bulkSignSignedDocs(ws, { nodeIds: ids, code }));
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return errorResult(err);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
server.registerTool(
|
|
474
|
+
'rootr_verify_ledger',
|
|
475
|
+
{
|
|
476
|
+
title: 'Verify the workspace signature ledger',
|
|
477
|
+
description:
|
|
478
|
+
'Replay the whole workspace signature ledger (every signature ever made, hash-chained in order) and ' +
|
|
479
|
+
'report whether it is intact, how many links it holds, its head hash, and the first broken sequence ' +
|
|
480
|
+
'if any. A broken ledger means someone edited or deleted signature rows in the database.',
|
|
481
|
+
annotations: { readOnlyHint: true },
|
|
482
|
+
inputSchema: {
|
|
483
|
+
workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
async ({ workspace }) => {
|
|
487
|
+
try {
|
|
488
|
+
const ws = requireWorkspace(workspace);
|
|
489
|
+
return jsonResult(await makeClient(ws).verifyLedger(ws));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
return errorResult(err);
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
}
|
|
@@ -152,15 +152,27 @@ export function registerWorkspaceTools(server) {
|
|
|
152
152
|
server.registerTool(
|
|
153
153
|
'rootr_scaffold_plan',
|
|
154
154
|
{
|
|
155
|
-
title: '
|
|
155
|
+
title: 'Step 1 of scaffolding — get the design method, then think and ask',
|
|
156
156
|
description:
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
162
|
-
'
|
|
163
|
-
'
|
|
157
|
+
'ALWAYS call this first. There are NO templates and NO domain skeletons behind it — deliberately. It ' +
|
|
158
|
+
'does not classify your intent and it will not hand you a structure; what comes back is the METHOD: ' +
|
|
159
|
+
'`analyzeFirst` (the questions to answer about how this job actually runs before you design anything — ' +
|
|
160
|
+
'the daily and weekly rhythm, what piles up, what moves through stages, what arrives from outside, where ' +
|
|
161
|
+
'money is added up, what causes trouble when it is late, and above all what is UNIQUE to this job), ' +
|
|
162
|
+
'`designRules` (how to turn those answers into structure — nouns that pile up become DATABASEs, "one A ' +
|
|
163
|
+
'has many B" becomes a relation, stages become select + board, dates become calendar, outside input ' +
|
|
164
|
+
'becomes FORM + collectInto, calculation becomes SPREADSHEET, counting becomes rollup), `buildingBlocks` ' +
|
|
165
|
+
'(what the product actually offers), `questionPolicy` (ask the user 3-10 questions — you WRITE them, ' +
|
|
166
|
+
'none are provided), and `loop` (the whole sequence). Design it from scratch every time: a bakery, a ' +
|
|
167
|
+
'dental clinic and an SRE team need different things, and no catalogue of skeletons could hold them. ' +
|
|
168
|
+
'Treat it exactly like a coding request. When someone says "build me this" you ask a few clarifying ' +
|
|
169
|
+
'questions, write the code, compile it, run the tests, leave a README, and report. Same sequence here: ' +
|
|
170
|
+
'analyzeFirst = understanding the requirement · questionPolicy = the questions you ask · designRules = ' +
|
|
171
|
+
'writing the code · rootr_scaffold_review = compiling (fix what it flags, compile again) · ' +
|
|
172
|
+
'rootr_scaffold_apply = building · the how-to-use manual = the README · rootr_test_workspace = running ' +
|
|
173
|
+
'the tests. The user should only ever have to type that one sentence — ask your questions, take their ' +
|
|
174
|
+
'answers, and finish the whole thing in this conversation. If they do not answer or say "just do it", ' +
|
|
175
|
+
'assume the most plausible answers, build it anyway, and record the assumptions in the manual.',
|
|
164
176
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
165
177
|
inputSchema: {
|
|
166
178
|
intent: z.string().describe('Natural-language description of what the workspace/tree should be for'),
|
|
@@ -184,8 +196,10 @@ export function registerWorkspaceTools(server) {
|
|
|
184
196
|
description:
|
|
185
197
|
'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
|
|
186
198
|
'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
|
|
187
|
-
'PRECONDITION:
|
|
188
|
-
'
|
|
199
|
+
'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
|
|
200
|
+
'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
|
|
201
|
+
'response carries the same `review` back, so a low score here means you shipped something thin. ' +
|
|
202
|
+
'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
|
|
189
203
|
'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
|
|
190
204
|
'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
|
|
191
205
|
'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
|
|
@@ -209,15 +223,105 @@ export function registerWorkspaceTools(server) {
|
|
|
209
223
|
},
|
|
210
224
|
);
|
|
211
225
|
|
|
226
|
+
server.registerTool(
|
|
227
|
+
'rootr_scaffold_review',
|
|
228
|
+
{
|
|
229
|
+
title: 'Check a designed tree BEFORE building it — the compiler pass',
|
|
230
|
+
description:
|
|
231
|
+
'Step 3 of scaffolding (plan → ask → design → REVIEW → fix → apply). Give it the tree you designed and ' +
|
|
232
|
+
'it returns { score 0-100, counts, findings[], summary } — every finding is a concrete defect with the ' +
|
|
233
|
+
'node path, e.g. a table whose only column is the title (that is a list, not a table), a lifecycle ' +
|
|
234
|
+
'column with no board view, a date column with no calendar, a form with nowhere to collect answers, ' +
|
|
235
|
+
'tables that are never related to each other, an empty guide document. Fix what it flags and review ' +
|
|
236
|
+
'again — like re-running a compiler — until nothing at `error` level is left. The rules are ' +
|
|
237
|
+
'domain-agnostic, so this works for a bakery or a dental clinic exactly as well as for a SaaS team. ' +
|
|
238
|
+
'Creates nothing and needs no workspace, so run it as many times as you like before committing.',
|
|
239
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
240
|
+
inputSchema: {
|
|
241
|
+
tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
async ({ tree }) => {
|
|
245
|
+
try {
|
|
246
|
+
const client = makeClient();
|
|
247
|
+
const result = await client.scaffoldReview(parseTree(tree));
|
|
248
|
+
return jsonResult(result);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return errorResult(err);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
server.registerTool(
|
|
256
|
+
'rootr_review_workspace',
|
|
257
|
+
{
|
|
258
|
+
title: 'Check the workspace you ACTUALLY built — not the tree you sent',
|
|
259
|
+
description:
|
|
260
|
+
'Step 5, after building. `rootr_scaffold_review` grades the tree you DESIGNED; this grades what really ' +
|
|
261
|
+
'exists in the workspace right now — whatever tools made it. Run it after every build: a design can score ' +
|
|
262
|
+
'100 and still land broken (a form whose answers go nowhere, a view whose filter vanished, rows that never ' +
|
|
263
|
+
'got linked). Returns the same { score, counts, findings[], summary }. Pass `rootPath` to grade only the ' +
|
|
264
|
+
'subtree you just added, otherwise an older workspace drags the score down and the number stops meaning ' +
|
|
265
|
+
'anything. Fix what it flags with the normal tools (add rows, write the guide document, add the missing ' +
|
|
266
|
+
'columns/views) and run it again until it is clean.',
|
|
267
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
268
|
+
inputSchema: {
|
|
269
|
+
rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
|
|
270
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
async ({ rootPath, workspace }) => {
|
|
274
|
+
try {
|
|
275
|
+
const ws = requireWorkspace(workspace);
|
|
276
|
+
const client = makeClient(ws);
|
|
277
|
+
return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
return errorResult(err);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
server.registerTool(
|
|
285
|
+
'rootr_test_workspace',
|
|
286
|
+
{
|
|
287
|
+
title: 'RUN the workspace you built — does it actually work?',
|
|
288
|
+
description:
|
|
289
|
+
'The last step, and the one that catches what the two review tools cannot. `rootr_scaffold_review` grades ' +
|
|
290
|
+
'the tree you designed; `rootr_review_workspace` grades the shape of what got built; this one **exercises ' +
|
|
291
|
+
'it**: does every relation point at a row that really exists, do rollups and formulas produce a value on ' +
|
|
292
|
+
'real rows, do board/calendar/filter views reference columns that exist, can the form actually deposit an ' +
|
|
293
|
+
'answer (target table alive AND every question name matching a column — a mismatch makes answers vanish ' +
|
|
294
|
+
'with no error), are sheet formulas computed and free of #REF!/#DIV/0!. Returns { ran, passed, failed, ' +
|
|
295
|
+
'warned, checks[] }. It writes nothing, so run it as often as you like. A workspace can score 100 on both ' +
|
|
296
|
+
'reviews and still fail here — that is exactly the case this exists for. Pass `rootPath` to test only the ' +
|
|
297
|
+
'subtree you just built. Fix every `fail` and run it again.',
|
|
298
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
299
|
+
inputSchema: {
|
|
300
|
+
rootPath: z.string().optional().describe('Test only this subtree, e.g. "/동네 빵집 운영"'),
|
|
301
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
async ({ rootPath, workspace }) => {
|
|
305
|
+
try {
|
|
306
|
+
const ws = requireWorkspace(workspace);
|
|
307
|
+
const client = makeClient(ws);
|
|
308
|
+
return jsonResult(await client.scaffoldSelfTest(ws, rootPath));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return errorResult(err);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
212
315
|
server.registerTool(
|
|
213
316
|
'rootr_scaffold_apply',
|
|
214
317
|
{
|
|
215
318
|
title: 'Apply a scaffold tree to an existing workspace',
|
|
216
319
|
description:
|
|
217
320
|
'Create the given document tree inside an existing Rootr (루터) workspace, optionally rooted under an ' +
|
|
218
|
-
'existing path. Works with a workspace key (docs:write scope). PRECONDITION:
|
|
219
|
-
'
|
|
220
|
-
'bar as rootr_create_workspace — real building blocks with icons/views/
|
|
321
|
+
'existing path. Works with a workspace key (docs:write scope). PRECONDITION: rootr_scaffold_plan first, ask ' +
|
|
322
|
+
'its 3-10 clarifying questions, design from the answers, then rootr_scaffold_review and fix every ' +
|
|
323
|
+
'`error` finding (same quality bar as rootr_create_workspace — real building blocks with icons/views/' +
|
|
324
|
+
'seed rows, not thin markdown). The response carries the `review` back so you can see what stayed weak, and AFTER it lands run rootr_review_workspace (rootPath = your new subtree) — that one grades what really exists. ' +
|
|
221
325
|
'AFTER creating: if your runtime can receive HTTP callbacks, offer a webhook on the new subtree ' +
|
|
222
326
|
'(rootr_create_webhook with scopeNodeId) so changes flow back to you — skip your own actorId.',
|
|
223
327
|
annotations: { readOnlyHint: false, destructiveHint: false },
|