rootr-cli 0.9.0 → 0.10.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 CHANGED
@@ -202,6 +202,50 @@ rootr config # 등록된 키 목록 확인
202
202
  업그레이드 링크가 담긴 안내가 나옵니다. 응답 헤더로도 확인할 수 있습니다
203
203
  (`X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Window`).
204
204
 
205
+ ### 서명문서 · 연구노트 (sign)
206
+
207
+ 서명문서는 마크다운 문서인데 **올리는 순간 본문이 얼고**, 결재라인에 적힌 사람들이
208
+ 차례로 서명합니다. 서명마다 공인시점확인(TSA)과 해시 체이닝이 붙어 위·변조를
209
+ 증명할 수 있고, 서명된 문서는 삭제되지 않습니다(30년 보존).
210
+ 전자결재와 국가R&D 전자연구노트가 같은 기능 하나입니다.
211
+
212
+ ```bash
213
+ # 만들기 — 본문·결재라인까지 한 번에. 연구노트는 점검자만 정하면 두 단계가 자동
214
+ rootr sign new "/연구노트/2026-08-25 실험기록" --file note.md --reviewer <연구책임자 userId>
215
+
216
+ # 결재라인을 직접 짜려면 (배열 순서 = 서명 순서)
217
+ cat > line.json <<'EOF'
218
+ [ { "label": "기재자", "members": [{ "userId": "u1", "role": "AUTHOR" }] },
219
+ { "label": "부서장", "rule": "ANY", "members": [{ "userId": "u2" }, { "userId": "u3" }] } ]
220
+ EOF
221
+ rootr sign line /연구노트/2026-08-25\ 실험기록 --file line.json
222
+
223
+ # 올리기: 확인 코드를 메일로 받고 → 그 6자리를 넘긴다
224
+ rootr sign code /연구노트/2026-08-25\ 실험기록
225
+ rootr sign submit /연구노트/2026-08-25\ 실험기록 --code 123456
226
+
227
+ # 다음 사람: 내 차례인 것만 보고 → 서명 (또는 사유를 달아 반려)
228
+ rootr sign list --pending
229
+ rootr sign code <id> && rootr sign approve <id> --code 123456
230
+ rootr sign reject <id> --comment "근거 자료가 빠졌습니다" --code 123456
231
+
232
+ # 여러 건 한 번에 (코드 하나로 최대 100건)
233
+ rootr sign code --bulk
234
+ rootr sign bulk <id1> <id2> <id3> --code 123456
235
+
236
+ # 증명
237
+ rootr sign verify <id> # 본문·서명·시점확인·원장이 성한지
238
+ rootr sign ledger # 워크스페이스 원장 전체 재계산
239
+ ```
240
+
241
+ **확인 코드는 CLI가 대신 만들어 줄 수 없습니다.** 코드는 서명하는 사람 메일로만
242
+ 가고, 서버가 개인키를 보관하는 구조에서 그게 "본인이 눌렀다"의 유일한 증거입니다.
243
+ 10분 유효, 5회 틀리면 죽고, 30초 안에 다시 요청하면 거절됩니다.
244
+
245
+ 서명된 문서는 고치는 게 아니라 **개정본**을 냅니다(`rootr sign revise <id> --note "…"`).
246
+ 원본은 `SUPERSEDED`로 남습니다 — 지침이 "고친 흔적과 원본이 모두 보여야 한다"를
247
+ 요구하기 때문입니다.
248
+
205
249
  ### RCA 질의응답 (ask)
206
250
 
207
251
  ```bash
@@ -214,7 +258,7 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
214
258
 
215
259
  ## MCP 서버로 사용하기
216
260
 
217
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 96종:
261
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 134종:
218
262
 
219
263
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
220
264
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -352,6 +396,34 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
352
396
  - `title` 열은 추가로 만들 수도, 삭제할 수도 없습니다.
353
397
  - 삭제한 열의 값은 행 안에 남아 있지만 화면에는 나오지 않습니다 — 사용자 확인 후 호출하세요.
354
398
 
399
+ #### 서명문서 · 연구노트 (docs:read/docs:write)
400
+
401
+ | 도구 | 설명 |
402
+ |---|---|
403
+ | `rootr_create_signed_document` | 서명문서 만들기 — 본문·모드·결재라인까지 한 번에 |
404
+ | `rootr_read_signed_document` | 결재라인 진행·서명 목록·내 차례인지(`canSign`) |
405
+ | `rootr_set_approval_line` | 결재라인 다시 짜기 (올리기 전에만) |
406
+ | `rootr_apply_research_note_preset` | 연구노트로 전환 + 기재자→점검자 두 단계 자동 |
407
+ | `rootr_request_signing_code` | 확인 코드를 서명자 메일로 발송 (서명 전 필수) |
408
+ | `rootr_submit_signed_document` | 올리기 — 본문이 얼고 기안자가 첫 서명 |
409
+ | `rootr_sign_document` | 내 차례에 서명 |
410
+ | `rootr_reject_signed_document` | 반려 (사유 필수) |
411
+ | `rootr_reopen_signed_document` | 아무도 서명 안 한 초안 다시 열기 |
412
+ | `rootr_revise_signed_document` | 개정본 만들기 (원본은 남는다) |
413
+ | `rootr_verify_signed_document` | 위변조 검증 — 본문·서명·시점확인·원장 |
414
+ | `rootr_list_signed_documents` | 목록 (`pendingMine`: 내 차례인 것만) |
415
+ | `rootr_bulk_sign_documents` | 코드 하나로 최대 100건 묶음 서명 |
416
+ | `rootr_verify_ledger` | 워크스페이스 원장(해시 체인) 전체 재계산 |
417
+
418
+ 에이전트가 대신 눌러 줄 수 없는 자리가 하나 있습니다 — **서명**. 확인 코드는
419
+ 사람 메일로만 가므로 흐름은 항상 `rootr_request_signing_code` → 사람에게 6자리를
420
+ 물어봄 → `rootr_sign_document` 입니다. 코드를 지어내면 403이고, 5회 틀리면 코드가
421
+ 죽습니다.
422
+
423
+ 표의 행마다 서명문서를 붙이려면 `rootr_create_database`에 `rowPageType: "SIGNED"`를
424
+ 주세요 — 그 표는 행을 열 때마다 서명문서(연구노트)가 만들어집니다(실험 기록 대장,
425
+ 점검 대장).
426
+
355
427
  #### 문서 버전 / 삭제
356
428
 
357
429
  | 도구 | 설명 |
package/bin/rootr.js CHANGED
@@ -41,9 +41,22 @@ const USAGE = `Rootr CLI v${pkg.version}
41
41
  rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
42
42
  rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
43
43
  rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
44
+ rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
45
+ rootr sign show <path|id> [--json] 결재라인 진행·서명 목록 보기
46
+ rootr sign line <path|id> --file <line.json> 결재라인 다시 짜기 (올리기 전에만)
47
+ rootr sign code <path|id> | --bulk 확인 코드를 내 메일로 보내기 (서명 전 필수)
48
+ rootr sign submit <path|id> --code <6자리> 올리기 — 본문이 얼고 기안자가 첫 서명
49
+ rootr sign approve <path|id> --code <6자리> 내 차례에 서명
50
+ rootr sign reject <path|id> --comment <사유> [--code <6자리>] 반려 (사유 필수)
51
+ rootr sign reopen <path|id> 아무도 서명 안 한 초안 다시 열기
52
+ rootr sign revise <path|id> --note <사유> 개정본 만들기 (원본은 그대로 남는다)
53
+ rootr sign verify <path|id> [--json] 위변조 검증 (서명·해시·시점확인)
54
+ rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
55
+ rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 서명 (코드 하나로)
56
+ rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
44
57
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
45
58
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
46
- rootr mcp MCP stdio 서버 구동 (도구 90종+ — README 참고)
59
+ rootr mcp MCP stdio 서버 구동 (도구 130종+ — README 참고)
47
60
  rootr --help 도움말
48
61
  rootr --version 버전 출력
49
62
 
@@ -840,6 +853,233 @@ async function resolveDatabaseId(client, target) {
840
853
  return target;
841
854
  }
842
855
 
856
+ // ── SIGN-001: 서명문서 · 연구노트 ────────────────────────────────────────
857
+ //
858
+ // 서명·올리기·반려는 메일로 받은 6자리 확인 코드를 함께 보내야 한다. 서버가
859
+ // 개인키를 보관하는 구조에서 "본인이 눌렀다"의 유일한 증거라, CLI가 대신
860
+ // 눌러 줄 수 있는 자리가 아니다 — `rootr sign code`로 코드를 받고, 메일에서
861
+ // 읽은 값을 `--code`로 넘긴다.
862
+
863
+ const SIGN_USAGE =
864
+ '사용법: rootr sign new|show|line|code|submit|approve|reject|reopen|revise|verify|list|bulk|ledger …\n' +
865
+ ' (자세한 건 rootr --help)';
866
+
867
+ function signWorkspace(client, args) {
868
+ const ws = args.ws || client.workspace;
869
+ if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
870
+ return ws;
871
+ }
872
+
873
+ /** 서명 관련 인자도 문서 도구들과 같은 규칙 — 경로로 줘도 되게. */
874
+ async function resolveSignedId(client, target) {
875
+ if (!target) throw new Error(SIGN_USAGE);
876
+ return requireResolved(await resolveTarget(client, target));
877
+ }
878
+
879
+ function requireCode(args, what) {
880
+ if (typeof args.code !== 'string' || !args.code.trim()) {
881
+ throw new Error(
882
+ `${what}에는 확인 코드가 필요합니다: 먼저 rootr sign code <path|id> 로 메일을 받고 --code <6자리> 로 넘기세요.`,
883
+ );
884
+ }
885
+ return args.code.trim();
886
+ }
887
+
888
+ const SIGN_STATUS_KO = {
889
+ DRAFT: '작성 중',
890
+ IN_PROGRESS: '서명 진행 중',
891
+ COMPLETED: '완료',
892
+ REJECTED: '반려됨',
893
+ SUPERSEDED: '개정되어 대체됨',
894
+ VOIDED: '무효',
895
+ };
896
+
897
+ function printSignedState(st) {
898
+ console.log(`${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (연구노트)' : ''}`);
899
+ if (st.revisionNo) console.log(`개정 ${st.revisionNo}판${st.revisionNote ? ` — ${st.revisionNote}` : ''}`);
900
+ if (st.latestRevisionId) console.log(`※ 더 최신 판이 있습니다: ${st.latestRevisionId} (${st.latestRevisionNo}판)`);
901
+ console.log(st.lockedAt ? `잠김: ${st.lockedAt} (본문·결재라인 수정 불가)` : '잠기지 않음 (아직 고칠 수 있음)');
902
+ if (!st.contentIntact) console.log('⚠️ 본문이 서명 당시와 다릅니다 — rootr sign verify 로 확인하세요.');
903
+ for (const step of st.steps ?? []) {
904
+ const here = step.order === st.currentStepOrder ? ' ← 지금 이 차례' : '';
905
+ const rule = step.rule === 'ANY' ? '아무나 1명' : step.rule === 'N_OF_M' ? `${step.requiredCount}명` : '전원';
906
+ console.log(`\n${step.order}. ${step.label} (${rule})${here}`);
907
+ for (const m of step.members ?? []) {
908
+ console.log(` - ${m.userId} ${m.role} ${m.state}${m.actedAt ? ` ${m.actedAt}` : ''}${m.comment ? ` "${m.comment}"` : ''}`);
909
+ }
910
+ }
911
+ if (st.signatures?.length) {
912
+ console.log('\n서명:');
913
+ for (const sig of st.signatures) {
914
+ console.log(
915
+ ` #${sig.seq} ${sig.signerName} <${sig.signerEmail}> ${sig.role} ${sig.signedAt}` +
916
+ (sig.timestampPending ? ' [시점확인 대기중]' : ` [시점확인 ${sig.tsTime} · ${sig.tsaName}]`),
917
+ );
918
+ }
919
+ }
920
+ if (st.canSign) console.log('\n→ 지금 서명할 수 있습니다: rootr sign code <id> 후 rootr sign approve <id> --code <6자리>');
921
+ }
922
+
923
+ async function cmdSign(args) {
924
+ const sub = args._[0];
925
+ const client = makeClient();
926
+
927
+ switch (sub) {
928
+ case 'new': {
929
+ const target = args._[1];
930
+ if (!target) throw new Error('사용법: rootr sign new <경로> [--file <본문.md>] [--reviewer <userId>] [--line <line.json>]');
931
+ const ws = signWorkspace(client, args);
932
+ const content =
933
+ typeof args.file === 'string'
934
+ ? readFileSync(args.file, 'utf8')
935
+ : args._[2] !== undefined
936
+ ? String(args._[2])
937
+ : await readStdin();
938
+ const approvalLine =
939
+ typeof args.line === 'string' ? JSON.parse(readFileSync(args.line, 'utf8')) : undefined;
940
+ const noteMeta = typeof args.meta === 'string' ? JSON.parse(readFileSync(args.meta, 'utf8')) : undefined;
941
+ const body = {
942
+ content: content || '',
943
+ createParents: true,
944
+ approvalLine,
945
+ noteMeta,
946
+ reviewerUserId: typeof args.reviewer === 'string' ? args.reviewer : undefined,
947
+ mode: typeof args.mode === 'string' ? args.mode : args.reviewer ? 'RESEARCH_NOTE' : undefined,
948
+ };
949
+ if (target.startsWith('/')) body.path = target;
950
+ else body.name = target;
951
+ const st = await client.createSignedDoc(ws, body);
952
+ console.log(`서명문서 생성: ${st.path ?? st.name} (${st.nodeId})`);
953
+ if (!st.steps?.length) {
954
+ console.log('결재라인이 아직 없습니다 — rootr sign line <id> --file <line.json> 으로 짜세요.');
955
+ } else {
956
+ printSignedState(st);
957
+ }
958
+ return;
959
+ }
960
+ case 'show': {
961
+ const st = await client.getSignedDoc(await resolveSignedId(client, args._[1]));
962
+ if (args.json) return console.log(JSON.stringify(st, null, 2));
963
+ return printSignedState(st);
964
+ }
965
+ case 'line': {
966
+ const id = await resolveSignedId(client, args._[1]);
967
+ const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
968
+ if (!raw) throw new Error('결재라인 JSON이 없습니다 (--file 또는 표준입력).');
969
+ const parsed = JSON.parse(raw);
970
+ const steps = Array.isArray(parsed) ? parsed : parsed.steps;
971
+ const st = await client.setApprovalLine(id, steps);
972
+ console.log(`결재라인 ${st.steps.length}단계 저장됨.`);
973
+ return printSignedState(st);
974
+ }
975
+ case 'code': {
976
+ if (args.bulk) {
977
+ const ws = signWorkspace(client, args);
978
+ const out = await client.requestBulkSigningCode(ws);
979
+ console.log(`묶음 서명용 확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
980
+ return;
981
+ }
982
+ const out = await client.requestSigningCode(await resolveSignedId(client, args._[1]));
983
+ console.log(`확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
984
+ return;
985
+ }
986
+ case 'submit': {
987
+ const id = await resolveSignedId(client, args._[1]);
988
+ const st = await client.submitSignedDoc(id, { code: requireCode(args, '올리기') });
989
+ console.log('올렸습니다 — 본문이 얼었고 기안자 서명이 찍혔습니다.');
990
+ return printSignedState(st);
991
+ }
992
+ case 'approve':
993
+ case 'sign': {
994
+ const id = await resolveSignedId(client, args._[1]);
995
+ const st = await client.signSignedDoc(id, { code: requireCode(args, '서명') });
996
+ console.log('서명했습니다.');
997
+ return printSignedState(st);
998
+ }
999
+ case 'reject': {
1000
+ const id = await resolveSignedId(client, args._[1]);
1001
+ if (typeof args.comment !== 'string' || !args.comment.trim()) {
1002
+ throw new Error('반려에는 사유가 필요합니다: --comment "근거 자료가 빠졌습니다"');
1003
+ }
1004
+ const st = await client.rejectSignedDoc(id, { comment: args.comment, code: args.code });
1005
+ console.log('반려했습니다 — 작성자가 다시 고칠 수 있습니다.');
1006
+ return printSignedState(st);
1007
+ }
1008
+ case 'reopen': {
1009
+ const st = await client.reopenSignedDoc(await resolveSignedId(client, args._[1]));
1010
+ console.log('다시 열었습니다 — 본문과 결재라인을 고칠 수 있습니다.');
1011
+ return printSignedState(st);
1012
+ }
1013
+ case 'revise': {
1014
+ const id = await resolveSignedId(client, args._[1]);
1015
+ if (typeof args.note !== 'string' || !args.note.trim()) {
1016
+ throw new Error('개정에는 사유가 필요합니다: --note "측정값 단위 정정"');
1017
+ }
1018
+ const out = await client.reviseSignedDoc(id, args.note);
1019
+ console.log(`개정본을 만들었습니다: ${out.nodeId} (${out.revisionNo}판). 원본은 그대로 남습니다.`);
1020
+ return printSignedState(await client.getSignedDoc(out.nodeId));
1021
+ }
1022
+ case 'verify': {
1023
+ const v = await client.verifySignedDoc(await resolveSignedId(client, args._[1]));
1024
+ if (args.json) return console.log(JSON.stringify(v, null, 2));
1025
+ const mark = (ok) => (ok ? '✅' : '❌');
1026
+ console.log(`${v.name} [${SIGN_STATUS_KO[v.status] ?? v.status}]`);
1027
+ console.log(`${mark(v.contentIntact)} 본문이 서명 당시 그대로다`);
1028
+ console.log(`${mark(v.allSignaturesValid)} 서명이 전부 유효하고 체인이 이어진다`);
1029
+ console.log(`${mark(v.allTimestamped)} 서명마다 공인시점확인이 붙어 있다`);
1030
+ console.log(`${mark(v.ledgerIntact)} 워크스페이스 원장이 성하다${v.ledgerBrokenAtSeq ? ` (끊긴 지점 #${v.ledgerBrokenAtSeq})` : ''}`);
1031
+ for (const sig of v.signatures ?? []) {
1032
+ console.log(
1033
+ ` #${sig.seq} ${sig.signerName} ${sig.role} ${sig.signedAt} ` +
1034
+ `${mark(sig.signatureValid)}서명 ${mark(sig.chainLinkValid)}체인 ${mark(sig.matchesCurrentContent)}현재본문` +
1035
+ (sig.timestampPending ? ' [시점확인 대기중]' : ''),
1036
+ );
1037
+ }
1038
+ return;
1039
+ }
1040
+ case 'list': {
1041
+ const ws = signWorkspace(client, args);
1042
+ const rows = await client.listSignedDocs(ws, {
1043
+ status: typeof args.status === 'string' ? args.status : undefined,
1044
+ mode: typeof args.mode === 'string' ? args.mode : undefined,
1045
+ pendingMine: !!args.pending,
1046
+ });
1047
+ if (args.json) return console.log(JSON.stringify(rows, null, 2));
1048
+ if (!rows.length) return console.log(args.pending ? '내 차례인 서명문서가 없습니다.' : '서명문서가 없습니다.');
1049
+ for (const r of rows) {
1050
+ const waiting = r.waitingOn?.length ? ` 대기: ${r.waitingOn.join(', ')}` : '';
1051
+ console.log(`${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← 내 차례' : ''}`);
1052
+ }
1053
+ return;
1054
+ }
1055
+ case 'bulk': {
1056
+ const ws = signWorkspace(client, args);
1057
+ const targets = args._.slice(1);
1058
+ if (!targets.length) throw new Error('사용법: rootr sign bulk <id|경로 …> --code <6자리>');
1059
+ const code = requireCode(args, '묶음 서명');
1060
+ const nodeIds = [];
1061
+ for (const t of targets) nodeIds.push(await resolveSignedId(client, t));
1062
+ const out = await client.bulkSignSignedDocs(ws, { nodeIds, code });
1063
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
1064
+ const failed = (out.results ?? []).filter((r) => !r.ok);
1065
+ console.log(`서명 ${out.signed ?? 0}건, 건너뜀 ${failed.length}건`);
1066
+ for (const f of failed) console.log(` 건너뜀 ${f.nodeId}: ${f.error}`);
1067
+ return;
1068
+ }
1069
+ case 'ledger': {
1070
+ const ws = signWorkspace(client, args);
1071
+ const v = await client.verifyLedger(ws);
1072
+ if (args.json) return console.log(JSON.stringify(v, null, 2));
1073
+ console.log(`${v.ok ? '✅ 원장이 성합니다' : '❌ 원장이 끊겼습니다'} — 서명 ${v.count}건`);
1074
+ if (v.headHash) console.log(`머리 해시: ${v.headHash}`);
1075
+ if (!v.ok) console.log(`끊긴 지점: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`);
1076
+ return;
1077
+ }
1078
+ default:
1079
+ throw new Error(SIGN_USAGE);
1080
+ }
1081
+ }
1082
+
843
1083
  async function main() {
844
1084
  const argv = process.argv.slice(2);
845
1085
  const args = parseArgs(argv);
@@ -889,6 +1129,8 @@ async function main() {
889
1129
  return cmdForm(args);
890
1130
  case 'access':
891
1131
  return cmdAccess(args);
1132
+ case 'sign':
1133
+ return cmdSign(args);
892
1134
  case 'mcp':
893
1135
  return cmdMcp(args);
894
1136
  default:
package/lib/client.js CHANGED
@@ -644,10 +644,21 @@ export class RootrClient {
644
644
  * building a table by adding columns one at a time made the order depend on
645
645
  * the order of the calls, which is how migrated tables came out scrambled.
646
646
  */
647
- async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows } = {}) {
647
+ async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType } = {}) {
648
648
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
649
649
  method: 'POST',
650
- body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows },
650
+ body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType },
651
+ });
652
+ }
653
+
654
+ /**
655
+ * SIGN-001 — 이 표의 행을 열었을 때 나오는 상세 문서를 서명문서로 바꾼다.
656
+ * 그 뒤로 만들어지는 행마다 서명문서(연구노트)가 붙는다.
657
+ */
658
+ async setDatabaseRowPageType(databaseId, rowPageType) {
659
+ return this.request(`/databases/${encodeURIComponent(databaseId)}`, {
660
+ method: 'PATCH',
661
+ body: { rowPageType },
651
662
  });
652
663
  }
653
664
 
@@ -655,10 +666,13 @@ export class RootrClient {
655
666
  * 뷰를 하나 **덧붙인다**(기존 뷰는 그대로). 표를 만들 때만 뷰를 정할 수 있어서,
656
667
  * 노션에서 뷰가 넷인 표를 옮기면 하나밖에 못 두던 문제를 푼다.
657
668
  */
658
- async createDatabaseView(databaseId, { name, type, groupBy, dateProp, endDateProp } = {}) {
669
+ async createDatabaseView(
670
+ databaseId,
671
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths } = {},
672
+ ) {
659
673
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
660
674
  method: 'POST',
661
- body: { name, type, groupBy, dateProp, endDateProp },
675
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths },
662
676
  });
663
677
  }
664
678
 
@@ -1291,4 +1305,123 @@ export class RootrClient {
1291
1305
  if (data && Array.isArray(data.links)) return data.links;
1292
1306
  return [];
1293
1307
  }
1308
+
1309
+ // ── SIGN-001: 서명문서 · 연구노트 ────────────────────────────────────
1310
+ //
1311
+ // 마크다운 문서인데 올리는 순간 본문이 얼고, 결재라인에 적힌 사람들이 차례로
1312
+ // 서명하면 서명마다 시점확인(TSA)과 해시 체이닝이 붙는다. 서명·올리기·반려는
1313
+ // 메일로 받은 6자리 확인 코드를 함께 보내야 한다 — 서버가 개인키를 보관하는
1314
+ // 구조에서 "본인이 눌렀다"의 유일한 증거라, 코드 없이 대신 눌러 줄 수는 없다.
1315
+
1316
+ /** 서명문서 만들기 — 본문·모드·결재라인까지 한 번에. */
1317
+ async createSignedDoc(workspaceId, body = {}) {
1318
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/signed-docs`, {
1319
+ method: 'POST',
1320
+ body,
1321
+ });
1322
+ }
1323
+
1324
+ /** 지금 상태: 결재라인·단계별 진행·서명 목록·본문이 그대로인지. */
1325
+ async getSignedDoc(nodeId) {
1326
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}`);
1327
+ }
1328
+
1329
+ /** 결재라인 다시 짜기(올리기 전에만). steps 순서가 곧 서명 순서다. */
1330
+ async setApprovalLine(nodeId, steps) {
1331
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/approval-line`, {
1332
+ method: 'PUT',
1333
+ body: { steps },
1334
+ });
1335
+ }
1336
+
1337
+ /** 연구노트 프리셋: 기재자(나) → 점검자 두 단계. */
1338
+ async applyResearchNotePreset(nodeId, { reviewerUserId, noteMeta } = {}) {
1339
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/research-note-preset`, {
1340
+ method: 'POST',
1341
+ body: { reviewerUserId, noteMeta },
1342
+ });
1343
+ }
1344
+
1345
+ /** 확인 코드를 본인 메일로 보낸다. 문서 하나용. */
1346
+ async requestSigningCode(nodeId) {
1347
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/request-code`, {
1348
+ method: 'POST',
1349
+ body: {},
1350
+ });
1351
+ }
1352
+
1353
+ /** 확인 코드 — 묶음 서명용(워크스페이스 단위로 한 번). */
1354
+ async requestBulkSigningCode(workspaceId) {
1355
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/signed-docs/request-code`, {
1356
+ method: 'POST',
1357
+ body: {},
1358
+ });
1359
+ }
1360
+
1361
+ /** 올리기 = 본문을 얼리고 기안자가 첫 도장을 찍는다. */
1362
+ async submitSignedDoc(nodeId, { code } = {}) {
1363
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/submit`, {
1364
+ method: 'POST',
1365
+ body: { code },
1366
+ });
1367
+ }
1368
+
1369
+ /** 내 차례에 서명한다. */
1370
+ async signSignedDoc(nodeId, { code } = {}) {
1371
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/sign`, {
1372
+ method: 'POST',
1373
+ body: { code },
1374
+ });
1375
+ }
1376
+
1377
+ /** 반려 — 사유는 필수다. 반려되면 다시 고칠 수 있게 풀린다. */
1378
+ async rejectSignedDoc(nodeId, { comment, code } = {}) {
1379
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/reject`, {
1380
+ method: 'POST',
1381
+ body: { comment, code },
1382
+ });
1383
+ }
1384
+
1385
+ /** 아직 아무도 서명하지 않은 초안을 다시 열어 고칠 수 있게 한다. */
1386
+ async reopenSignedDoc(nodeId) {
1387
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/reopen`, { method: 'POST' });
1388
+ }
1389
+
1390
+ /** 개정본 만들기 — 서명된 문서는 고치는 게 아니라 새 판을 낸다. */
1391
+ async reviseSignedDoc(nodeId, note) {
1392
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/revise`, {
1393
+ method: 'POST',
1394
+ body: { note },
1395
+ });
1396
+ }
1397
+
1398
+ /** 위변조 검증. 로그인 없이도 열려 있다(본문은 안 내려온다). */
1399
+ async verifySignedDoc(nodeId) {
1400
+ return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/verify`);
1401
+ }
1402
+
1403
+ /** 워크스페이스 서명문서 목록. pendingMine이면 "내 차례인 것"만. */
1404
+ async listSignedDocs(workspaceId, { status, mode, pendingMine } = {}) {
1405
+ const qs = new URLSearchParams();
1406
+ if (status) qs.set('status', status);
1407
+ if (mode) qs.set('mode', mode);
1408
+ if (pendingMine) qs.set('pendingMine', 'true');
1409
+ const q = qs.toString();
1410
+ return this.request(
1411
+ `/workspaces/${encodeURIComponent(workspaceId)}/signed-docs${q ? `?${q}` : ''}`,
1412
+ );
1413
+ }
1414
+
1415
+ /** 묶음 서명 — "이번 주 미점검 12건"을 한 번에. 코드 하나로 전부. */
1416
+ async bulkSignSignedDocs(workspaceId, { nodeIds, code } = {}) {
1417
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/signed-docs/bulk-sign`, {
1418
+ method: 'POST',
1419
+ body: { nodeIds, code },
1420
+ });
1421
+ }
1422
+
1423
+ /** 워크스페이스 원장(해시 체인) 전체 검증. */
1424
+ async verifyLedger(workspaceId) {
1425
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/ledger/verify`);
1426
+ }
1294
1427
  }
@@ -13,6 +13,61 @@ import {
13
13
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
14
14
  const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
15
15
 
16
+ /**
17
+ * VIEW-FILTER — 사람이 화면에서 거는 "이 조건만 보이게"를 에이전트도 걸 수 있게 한다.
18
+ * 조건은 전부 만족해야 하고(AND), 한 조건 안의 `values`는 그중 하나만 맞아도 된다(OR).
19
+ */
20
+ const FILTER_SHAPE = z.array(
21
+ z.object({
22
+ propId: z.string().describe('Column NAME or id the condition applies to'),
23
+ op: z
24
+ .enum([
25
+ 'is',
26
+ 'is_not',
27
+ 'contains',
28
+ 'is_empty',
29
+ 'is_not_empty',
30
+ 'today',
31
+ 'this_week',
32
+ 'this_month',
33
+ 'past',
34
+ 'upcoming',
35
+ 'before',
36
+ 'after',
37
+ 'on',
38
+ 'between',
39
+ 'eq',
40
+ 'gt',
41
+ 'lt',
42
+ 'checked',
43
+ 'unchecked',
44
+ ])
45
+ .describe('Comparison to apply'),
46
+ values: z
47
+ .array(z.string())
48
+ .optional()
49
+ .describe(
50
+ 'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
51
+ 'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
52
+ 'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
53
+ ),
54
+ }),
55
+ );
56
+
57
+ const FILTER_DESC =
58
+ 'Only rows matching EVERY condition are shown in this view (AND between conditions, OR inside one ' +
59
+ 'condition\'s values). This is the same filter a person sets on screen, so "검토 대기인 것만 보이게 해줘" ' +
60
+ 'is expressible: [{"propId":"구분","op":"is","values":["검토 대기"]}]. Rows are NOT deleted — other views ' +
61
+ 'still show them. A new row added while a filter is on is seeded with the filtered values so it does not ' +
62
+ 'vanish the moment it is created.';
63
+
64
+ const SORT_SHAPE = z.object({
65
+ propId: z.string().describe('Column NAME or id to sort by'),
66
+ dir: z.enum(['asc', 'desc']).describe('asc = 오름차순, desc = 내림차순'),
67
+ });
68
+
69
+ const SORT_DESC = 'Row order in this view.';
70
+
16
71
  /**
17
72
  * DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
18
73
  * 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
@@ -143,10 +198,18 @@ export function registerDatabaseTools(server) {
143
198
  .array(z.object({ values: z.record(z.unknown()) }))
144
199
  .optional()
145
200
  .describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
201
+ rowPageType: z
202
+ .enum(['DOCUMENT', 'SIGNED'])
203
+ .optional()
204
+ .describe(
205
+ 'SIGN-001 — what a row\'s detail page is. "SIGNED" makes every row open as a 서명문서/연구노트 ' +
206
+ '(freezes on submit, ordered signatures + RFC 3161 timestamps, undeletable). Use it for 실험 기록 · ' +
207
+ '점검 대장 · 결재 대장 표. Default "DOCUMENT".',
208
+ ),
146
209
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
147
210
  },
148
211
  },
149
- async ({ name, parentId, path, createParents, icon, properties, views, rows, workspace }) => {
212
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
150
213
  try {
151
214
  const ws = requireWorkspace(workspace);
152
215
  const client = makeClient(ws);
@@ -160,6 +223,7 @@ export function registerDatabaseTools(server) {
160
223
  properties,
161
224
  views,
162
225
  rows,
226
+ rowPageType,
163
227
  });
164
228
  return jsonResult(database);
165
229
  } catch (err) {
@@ -189,13 +253,31 @@ export function registerDatabaseTools(server) {
189
253
  groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
190
254
  dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
191
255
  endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
256
+ filters: FILTER_SHAPE.optional().describe(FILTER_DESC),
257
+ sort: SORT_SHAPE.optional().describe(SORT_DESC),
258
+ colWidths: z
259
+ .record(z.number())
260
+ .optional()
261
+ .describe(
262
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
263
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
264
+ ),
192
265
  },
193
266
  },
194
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp }) => {
267
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
195
268
  try {
196
269
  const client = makeClient();
197
270
  const id = await resolveNodeId(client, databaseId);
198
- const view = await client.createDatabaseView(id, { name, type, groupBy, dateProp, endDateProp });
271
+ const view = await client.createDatabaseView(id, {
272
+ name,
273
+ type,
274
+ groupBy,
275
+ dateProp,
276
+ endDateProp,
277
+ filters,
278
+ sort,
279
+ colWidths,
280
+ });
199
281
  return jsonResult(view);
200
282
  } catch (err) {
201
283
  return errorResult(err);
@@ -250,9 +332,29 @@ export function registerDatabaseTools(server) {
250
332
  groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
251
333
  dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
252
334
  endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
335
+ filters: FILTER_SHAPE.nullable().optional().describe(FILTER_DESC + ' null 또는 []을 주면 조건을 전부 없앤다.'),
336
+ sort: SORT_SHAPE.nullable().optional().describe(SORT_DESC + ' null을 주면 정렬을 끈다.'),
337
+ colWidths: z
338
+ .record(z.number())
339
+ .optional()
340
+ .describe(
341
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
342
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
343
+ ),
253
344
  },
254
345
  },
255
- async ({ databaseId, viewId, hiddenProps, name, groupBy, dateProp, endDateProp }) => {
346
+ async ({
347
+ databaseId,
348
+ viewId,
349
+ hiddenProps,
350
+ name,
351
+ groupBy,
352
+ dateProp,
353
+ endDateProp,
354
+ filters,
355
+ sort,
356
+ colWidths,
357
+ }) => {
256
358
  try {
257
359
  const client = makeClient();
258
360
  const id = await resolveNodeId(client, databaseId);
@@ -262,6 +364,9 @@ export function registerDatabaseTools(server) {
262
364
  groupBy,
263
365
  dateProp,
264
366
  endDateProp,
367
+ filters,
368
+ sort,
369
+ colWidths,
265
370
  });
266
371
  return jsonResult({ view });
267
372
  } catch (err) {
@@ -0,0 +1,467 @@
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.string().describe('워크스페이스 멤버의 userId (외부인은 아직 결재자가 될 수 없다)'),
39
+ role: z
40
+ .enum(['AUTHOR', 'REVIEWER', 'APPROVER'])
41
+ .optional()
42
+ .describe('AUTHOR=기재자, REVIEWER=점검자, APPROVER=결재자. 생략하면 자리로 정해진다'),
43
+ }),
44
+ )
45
+ .min(1),
46
+ });
47
+
48
+ const CODE_HINT =
49
+ '메일로 받은 6자리 확인 코드. 먼저 rootr_request_signing_code 를 부르면 사용자 메일로 가고, ' +
50
+ '사용자가 알려 준 값을 그대로 넣는다. 절대 지어내지 말 것 — 틀리면 403이고 5회 틀리면 코드가 죽는다.';
51
+
52
+ export function registerSignedDocTools(server) {
53
+ server.registerTool(
54
+ 'rootr_create_signed_document',
55
+ {
56
+ title: 'Create a Rootr signed document',
57
+ description:
58
+ 'Create a SIGNED document (서명문서 · 전자결재 · 국가R&D 전자연구노트) in a Rootr (루터) workspace. ' +
59
+ 'A markdown document that FREEZES on submit: after that the body, attachments and approval line ' +
60
+ 'cannot be edited, every signature carries an RFC 3161 timestamp, and the whole workspace keeps a ' +
61
+ 'hash-chained ledger so tampering is detectable. Signed documents cannot be deleted (30-year ' +
62
+ 'retention). Use this instead of a plain DOCUMENT whenever the content must be provable — 결재/승인 ' +
63
+ '문서, 연구노트, 점검 기록, 회의 확인서. ' +
64
+ 'Flow: create (body + approval line) → edit the body freely with rootr_write/rootr_append → ' +
65
+ 'rootr_request_signing_code → ask the human for the 6-digit code → rootr_submit_signed_document ' +
66
+ '(freezes + first signature) → each remaining signer does request-code + rootr_sign_document.',
67
+ annotations: { readOnlyHint: false, destructiveHint: false },
68
+ inputSchema: {
69
+ name: z.string().optional().describe('문서 이름, 예: "2026-08-25 실험 기록"'),
70
+ path: z
71
+ .string()
72
+ .optional()
73
+ .describe('트리 경로로 만들 때, 예: "/연구노트/2026-08-25 실험기록" (parentId 대신)'),
74
+ parentId: z.string().optional().describe(`부모 폴더 id${NODE_TARGET_HINT}`),
75
+ createParents: z.boolean().optional().describe('path를 쓸 때 없는 상위 폴더까지 만든다'),
76
+ icon: z.string().optional().describe('이모지 아이콘'),
77
+ content: z.string().optional().describe('본문 마크다운. 올리기 전까지는 얼마든지 고칠 수 있다'),
78
+ mode: z
79
+ .enum(['APPROVAL', 'RESEARCH_NOTE'])
80
+ .optional()
81
+ .describe('APPROVAL=전자결재(기본), RESEARCH_NOTE=연구노트'),
82
+ reviewerUserId: z
83
+ .string()
84
+ .optional()
85
+ .describe(
86
+ '연구노트 프리셋용 점검자(연구책임자) userId. approvalLine 없이 이것만 주면 ' +
87
+ '"기재자(만든 사람) → 점검자" 두 단계가 자동으로 짜인다',
88
+ ),
89
+ noteMeta: z
90
+ .record(z.any())
91
+ .optional()
92
+ .describe('연구노트 표지 정보: 과제번호·과제명·연구책임자·기관 등'),
93
+ approvalLine: z
94
+ .array(APPROVAL_STEP)
95
+ .optional()
96
+ .describe('결재라인. 배열 순서가 곧 서명 순서다'),
97
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
98
+ },
99
+ },
100
+ async ({ parentId, workspace, ...rest }) => {
101
+ try {
102
+ const ws = requireWorkspace(workspace);
103
+ const client = makeClient(ws);
104
+ parentId = await resolveOptionalNodeId(client, parentId);
105
+ return jsonResult(await client.createSignedDoc(ws, { ...rest, parentId }));
106
+ } catch (err) {
107
+ return errorResult(err);
108
+ }
109
+ },
110
+ );
111
+
112
+ server.registerTool(
113
+ 'rootr_read_signed_document',
114
+ {
115
+ title: 'Read a Rootr signed document state',
116
+ description:
117
+ 'Read a SIGNED document\'s signing state: mode, status (DRAFT/IN_PROGRESS/COMPLETED/REJECTED/' +
118
+ 'SUPERSEDED), whether it is locked, the approval line with each step\'s progress, every signature so far ' +
119
+ '(signer, time, TSA timestamp), whether the body still matches the frozen hash, and whether YOU can ' +
120
+ 'sign right now (canSign). Read the body itself with rootr_read.',
121
+ annotations: { readOnlyHint: true },
122
+ inputSchema: {
123
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
124
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
125
+ },
126
+ },
127
+ async ({ nodeId, workspace }) => {
128
+ try {
129
+ const client = makeClient(workspace);
130
+ return jsonResult(await client.getSignedDoc(await resolveNodeId(client, nodeId)));
131
+ } catch (err) {
132
+ return errorResult(err);
133
+ }
134
+ },
135
+ );
136
+
137
+ server.registerTool(
138
+ 'rootr_set_approval_line',
139
+ {
140
+ title: 'Set a signed document approval line',
141
+ description:
142
+ 'Replace the approval line (결재라인) of a SIGNED document. The array order IS the signing order; ' +
143
+ 'each step can require everyone (ALL), anyone (ANY) or N of M. Only possible BEFORE submit — once ' +
144
+ 'the document is submitted the line is frozen with the body (409 SIGNED_DOC_LOCKED); reject it first ' +
145
+ 'to reopen. All members must be workspace members.',
146
+ annotations: { readOnlyHint: false, destructiveHint: true },
147
+ inputSchema: {
148
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
149
+ steps: z.array(APPROVAL_STEP).min(1).describe('단계 배열. 순서가 곧 서명 순서다'),
150
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
151
+ },
152
+ },
153
+ async ({ nodeId, steps, workspace }) => {
154
+ try {
155
+ const client = makeClient(workspace);
156
+ return jsonResult(await client.setApprovalLine(await resolveNodeId(client, nodeId), steps));
157
+ } catch (err) {
158
+ return errorResult(err);
159
+ }
160
+ },
161
+ );
162
+
163
+ server.registerTool(
164
+ 'rootr_apply_research_note_preset',
165
+ {
166
+ title: 'Turn a signed document into a research note',
167
+ description:
168
+ 'Switch a SIGNED document to RESEARCH_NOTE mode and build the two-step line the Korean national R&D ' +
169
+ 'guideline requires: 기재자(the caller) → 점검자(the reviewer/연구책임자). Optionally stores the cover ' +
170
+ 'metadata (과제번호·과제명·기관). Use this instead of hand-building the approval line for 연구노트.',
171
+ annotations: { readOnlyHint: false, destructiveHint: true },
172
+ inputSchema: {
173
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
174
+ reviewerUserId: z.string().describe('점검자(연구책임자)의 userId'),
175
+ noteMeta: z.record(z.any()).optional().describe('표지 정보: 과제번호·과제명·연구책임자·기관 등'),
176
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
177
+ },
178
+ },
179
+ async ({ nodeId, reviewerUserId, noteMeta, workspace }) => {
180
+ try {
181
+ const client = makeClient(workspace);
182
+ return jsonResult(
183
+ await client.applyResearchNotePreset(await resolveNodeId(client, nodeId), {
184
+ reviewerUserId,
185
+ noteMeta,
186
+ }),
187
+ );
188
+ } catch (err) {
189
+ return errorResult(err);
190
+ }
191
+ },
192
+ );
193
+
194
+ server.registerTool(
195
+ 'rootr_request_signing_code',
196
+ {
197
+ title: 'Email a signing confirmation code',
198
+ description:
199
+ 'Send the 6-digit signing confirmation code to the signer\'s own email. REQUIRED before ' +
200
+ 'rootr_submit_signed_document / rootr_sign_document / rootr_reject_signed_document / ' +
201
+ 'rootr_bulk_sign_documents — the code is the only evidence that a HUMAN pressed sign, so you cannot ' +
202
+ 'read it and must ask the user for it. Valid 10 minutes, 5 wrong tries kill it, and re-requesting ' +
203
+ 'within 30 seconds is refused. Pass `bulk: true` (with no nodeId) for one code that covers a bulk sign.',
204
+ annotations: { readOnlyHint: false, destructiveHint: false },
205
+ inputSchema: {
206
+ nodeId: z
207
+ .string()
208
+ .optional()
209
+ .describe(`서명문서 노드 id${NODE_TARGET_HINT}. bulk가 true면 생략한다`),
210
+ bulk: z.boolean().optional().describe('묶음 서명용 코드를 받을 때 true'),
211
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
212
+ },
213
+ },
214
+ async ({ nodeId, bulk, workspace }) => {
215
+ try {
216
+ if (bulk) {
217
+ const ws = requireWorkspace(workspace);
218
+ const client = makeClient(ws);
219
+ const out = await client.requestBulkSigningCode(ws);
220
+ return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
221
+ }
222
+ const client = makeClient(workspace);
223
+ if (!nodeId) throw new Error('nodeId 또는 bulk: true 중 하나가 필요합니다.');
224
+ const out = await client.requestSigningCode(await resolveNodeId(client, nodeId));
225
+ return jsonResult({ ...out, next: '사용자에게 메일로 받은 6자리 코드를 물어보세요.' });
226
+ } catch (err) {
227
+ return errorResult(err);
228
+ }
229
+ },
230
+ );
231
+
232
+ server.registerTool(
233
+ 'rootr_submit_signed_document',
234
+ {
235
+ title: 'Submit (freeze + first-sign) a signed document',
236
+ description:
237
+ 'Submit a SIGNED document: the body and attachments FREEZE (their SHA-256 is recorded), the author ' +
238
+ 'signs first, and the next step\'s signers are notified. IRREVERSIBLE except by rejection — after ' +
239
+ 'this the only way to change the content is a revision (rootr_revise_signed_document). Needs the ' +
240
+ 'emailed code from rootr_request_signing_code.',
241
+ annotations: { readOnlyHint: false, destructiveHint: true },
242
+ inputSchema: {
243
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
244
+ code: z.string().describe(CODE_HINT),
245
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
246
+ },
247
+ },
248
+ async ({ nodeId, code, workspace }) => {
249
+ try {
250
+ const client = makeClient(workspace);
251
+ return jsonResult(await client.submitSignedDoc(await resolveNodeId(client, nodeId), { code }));
252
+ } catch (err) {
253
+ return errorResult(err);
254
+ }
255
+ },
256
+ );
257
+
258
+ server.registerTool(
259
+ 'rootr_sign_document',
260
+ {
261
+ title: 'Sign a Rootr signed document',
262
+ description:
263
+ 'Sign a SIGNED document as the current step\'s signer. Records an Ed25519 signature over the frozen ' +
264
+ 'content hash, requests an RFC 3161 timestamp, and links the signature into the workspace hash ledger. ' +
265
+ 'Only works when it is your turn (check canSign with rootr_read_signed_document). Needs the emailed ' +
266
+ 'code from rootr_request_signing_code — ask the human for it, never invent it.',
267
+ annotations: { readOnlyHint: false, destructiveHint: true },
268
+ inputSchema: {
269
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
270
+ code: z.string().describe(CODE_HINT),
271
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
272
+ },
273
+ },
274
+ async ({ nodeId, code, workspace }) => {
275
+ try {
276
+ const client = makeClient(workspace);
277
+ return jsonResult(await client.signSignedDoc(await resolveNodeId(client, nodeId), { code }));
278
+ } catch (err) {
279
+ return errorResult(err);
280
+ }
281
+ },
282
+ );
283
+
284
+ server.registerTool(
285
+ 'rootr_reject_signed_document',
286
+ {
287
+ title: 'Reject a signed document',
288
+ description:
289
+ 'Reject a SIGNED document with a reason. The document unlocks so the author can fix and resubmit; ' +
290
+ 'signatures already collected are recorded as part of the history and the rejection itself is ' +
291
+ 'notified to the author. The reason is required — "반려됨"만 남으면 고칠 수가 없다.',
292
+ annotations: { readOnlyHint: false, destructiveHint: true },
293
+ inputSchema: {
294
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
295
+ comment: z.string().describe('반려 사유 (필수)'),
296
+ code: z.string().optional().describe(CODE_HINT),
297
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
298
+ },
299
+ },
300
+ async ({ nodeId, comment, code, workspace }) => {
301
+ try {
302
+ const client = makeClient(workspace);
303
+ return jsonResult(
304
+ await client.rejectSignedDoc(await resolveNodeId(client, nodeId), { comment, code }),
305
+ );
306
+ } catch (err) {
307
+ return errorResult(err);
308
+ }
309
+ },
310
+ );
311
+
312
+ server.registerTool(
313
+ 'rootr_reopen_signed_document',
314
+ {
315
+ title: 'Reopen an unsigned signed-document draft',
316
+ description:
317
+ 'Unlock a SIGNED document that was submitted but that NOBODY has signed yet, so the body and the ' +
318
+ 'approval line can be edited again. Once any signature exists this refuses — make a revision instead ' +
319
+ '(rootr_revise_signed_document).',
320
+ annotations: { readOnlyHint: false, destructiveHint: true },
321
+ inputSchema: {
322
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
323
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
324
+ },
325
+ },
326
+ async ({ nodeId, workspace }) => {
327
+ try {
328
+ const client = makeClient(workspace);
329
+ return jsonResult(await client.reopenSignedDoc(await resolveNodeId(client, nodeId)));
330
+ } catch (err) {
331
+ return errorResult(err);
332
+ }
333
+ },
334
+ );
335
+
336
+ server.registerTool(
337
+ 'rootr_revise_signed_document',
338
+ {
339
+ title: 'Create a revision of a signed document',
340
+ description:
341
+ 'Create a NEW revision of an already-signed document. Signed documents are never edited in place — ' +
342
+ 'the guideline requires that both the original and the correction stay visible — so this copies the ' +
343
+ 'body into a fresh SIGNED node (revisionNo + 1), marks the original SUPERSEDED, and returns the new ' +
344
+ 'node id. The reason (note) is recorded on the revision.',
345
+ annotations: { readOnlyHint: false, destructiveHint: false },
346
+ inputSchema: {
347
+ nodeId: z.string().describe(`원본 서명문서 노드 id${NODE_TARGET_HINT}`),
348
+ note: z.string().describe('무엇을 왜 고치는지 (필수, 개정본에 남는다)'),
349
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
350
+ },
351
+ },
352
+ async ({ nodeId, note, workspace }) => {
353
+ try {
354
+ const client = makeClient(workspace);
355
+ return jsonResult(await client.reviseSignedDoc(await resolveNodeId(client, nodeId), note));
356
+ } catch (err) {
357
+ return errorResult(err);
358
+ }
359
+ },
360
+ );
361
+
362
+ server.registerTool(
363
+ 'rootr_verify_signed_document',
364
+ {
365
+ title: 'Verify a signed document (tamper check)',
366
+ description:
367
+ 'Verify a SIGNED document end to end: every Ed25519 signature is re-checked against its payload, ' +
368
+ 'every hash-chain link is recomputed, the current body is hashed and compared with the frozen hash, ' +
369
+ 'and the workspace ledger is replayed. Returns contentIntact / allSignaturesValid / allTimestamped / ' +
370
+ 'ledgerIntact plus per-signature detail. This is the answer to "이 문서가 위조되지 않았음을 증명해 봐".',
371
+ annotations: { readOnlyHint: true },
372
+ inputSchema: {
373
+ nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
374
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
375
+ },
376
+ },
377
+ async ({ nodeId, workspace }) => {
378
+ try {
379
+ const client = makeClient(workspace);
380
+ return jsonResult(await client.verifySignedDoc(await resolveNodeId(client, nodeId)));
381
+ } catch (err) {
382
+ return errorResult(err);
383
+ }
384
+ },
385
+ );
386
+
387
+ server.registerTool(
388
+ 'rootr_list_signed_documents',
389
+ {
390
+ title: 'List Rootr signed documents',
391
+ description:
392
+ 'List the SIGNED documents of a workspace with their status and who each one is waiting on. ' +
393
+ 'Set pendingMine: true for "내 차례인 것만" — the list a 연구책임자 opens to see this week\'s ' +
394
+ 'unchecked notes before bulk-signing them.',
395
+ annotations: { readOnlyHint: true },
396
+ inputSchema: {
397
+ status: z
398
+ .enum(['DRAFT', 'IN_PROGRESS', 'COMPLETED', 'REJECTED', 'SUPERSEDED', 'VOIDED'])
399
+ .optional(),
400
+ mode: z.enum(['APPROVAL', 'RESEARCH_NOTE']).optional(),
401
+ pendingMine: z.boolean().optional().describe('내 서명을 기다리는 것만'),
402
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
403
+ },
404
+ },
405
+ async ({ status, mode, pendingMine, workspace }) => {
406
+ try {
407
+ const ws = requireWorkspace(workspace);
408
+ const client = makeClient(ws);
409
+ return jsonResult(await client.listSignedDocs(ws, { status, mode, pendingMine }));
410
+ } catch (err) {
411
+ return errorResult(err);
412
+ }
413
+ },
414
+ );
415
+
416
+ server.registerTool(
417
+ 'rootr_bulk_sign_documents',
418
+ {
419
+ title: 'Sign many Rootr documents at once',
420
+ description:
421
+ 'Sign up to 100 SIGNED documents in one call with ONE emailed code (get it with ' +
422
+ 'rootr_request_signing_code + bulk: true). Documents where it is not your turn are skipped and ' +
423
+ 'reported, not failed. This is what makes 연구노트 usable in practice — a 연구책임자 signing "이번 주 ' +
424
+ '미점검 12건" without opening each one.',
425
+ annotations: { readOnlyHint: false, destructiveHint: true },
426
+ inputSchema: {
427
+ nodeIds: z.array(z.string()).min(1).max(100).describe('서명할 노드 id 목록 (경로도 됩니다)'),
428
+ code: z.string().describe(CODE_HINT + ' (bulk: true 로 받은 코드)'),
429
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
430
+ },
431
+ },
432
+ async ({ nodeIds, code, workspace }) => {
433
+ try {
434
+ const ws = requireWorkspace(workspace);
435
+ const client = makeClient(ws);
436
+ const ids = [];
437
+ for (const t of nodeIds) ids.push(await resolveNodeId(client, t));
438
+ return jsonResult(await client.bulkSignSignedDocs(ws, { nodeIds: ids, code }));
439
+ } catch (err) {
440
+ return errorResult(err);
441
+ }
442
+ },
443
+ );
444
+
445
+ server.registerTool(
446
+ 'rootr_verify_ledger',
447
+ {
448
+ title: 'Verify the workspace signature ledger',
449
+ description:
450
+ 'Replay the whole workspace signature ledger (every signature ever made, hash-chained in order) and ' +
451
+ 'report whether it is intact, how many links it holds, its head hash, and the first broken sequence ' +
452
+ 'if any. A broken ledger means someone edited or deleted signature rows in the database.',
453
+ annotations: { readOnlyHint: true },
454
+ inputSchema: {
455
+ workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
456
+ },
457
+ },
458
+ async ({ workspace }) => {
459
+ try {
460
+ const ws = requireWorkspace(workspace);
461
+ return jsonResult(await makeClient(ws).verifyLedger(ws));
462
+ } catch (err) {
463
+ return errorResult(err);
464
+ }
465
+ },
466
+ );
467
+ }
package/lib/mcp.js CHANGED
@@ -17,6 +17,7 @@ import { registerWhiteboardTools } from './mcp-tools/whiteboards.js';
17
17
  import { registerFormTools } from './mcp-tools/forms.js';
18
18
  import { registerRecordingTools } from './mcp-tools/recordings.js';
19
19
  import { registerAttachmentTools } from './mcp-tools/attachments.js';
20
+ import { registerSignedDocTools } from './mcp-tools/signed.js';
20
21
  import { withRateLimitSink } from './context.js';
21
22
 
22
23
  /** Warn once the workspace has burned this share of its quota. */
@@ -64,7 +65,7 @@ function withQuotaWarnings(server) {
64
65
  * Builds a fully-configured McpServer (all tool groups registered) without
65
66
  * connecting a transport. Shared by the local stdio CLI server below and by
66
67
  * the backend's hosted Streamable HTTP transport (src/mcp/mcp-transport.controller.ts),
67
- * so the 89 tool definitions have exactly one implementation.
68
+ * so the 134 tool definitions have exactly one implementation.
68
69
  */
69
70
  export function buildMcpServer({ name, version }) {
70
71
  const server = new McpServer(
@@ -111,6 +112,16 @@ export function buildMcpServer({ name, version }) {
111
112
  'and spends AI credits) → poll rootr_read_recording until status READY → read the transcript, ' +
112
113
  'rename speakers with rootr_rename_recording_speakers, and write the minutes yourself with rootr_write ' +
113
114
  '(preferred — no AI credits) or call rootr_summarize_recording for Rootr\'s built-in summary document; ' +
115
+ 'SIGNED = 서명문서 · 전자결재 · 국가R&D 전자연구노트 — a markdown document that FREEZES on submit, ' +
116
+ 'collects ordered Ed25519 signatures with RFC 3161 timestamps, and is hash-chained into a workspace ' +
117
+ 'ledger so tampering is provable; cannot be deleted (30-year retention). Use it whenever the content ' +
118
+ 'must be provable — 결재/승인 문서, 연구노트, 점검 기록 — via rootr_create_signed_document, ' +
119
+ 'rootr_read_signed_document, rootr_set_approval_line, rootr_apply_research_note_preset, ' +
120
+ 'rootr_submit_signed_document, rootr_sign_document, rootr_reject_signed_document, ' +
121
+ 'rootr_revise_signed_document, rootr_list_signed_documents, rootr_bulk_sign_documents, ' +
122
+ 'rootr_verify_signed_document, rootr_verify_ledger. NEVER invent a signing code: always ' +
123
+ 'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
124
+ 'that code is the only evidence a person (not an agent) pressed sign; ' +
114
125
  'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
115
126
  'Scaffolding discipline: ALWAYS rootr_scaffold_plan first, ask the user the 3-10 clarifying questions it ' +
116
127
  'prescribes, and only then design and create the tree — every answer must visibly shape the structure. ' +
@@ -138,6 +149,7 @@ export function buildMcpServer({ name, version }) {
138
149
  registerFormTools(server);
139
150
  registerRecordingTools(server);
140
151
  registerAttachmentTools(server);
152
+ registerSignedDocTools(server);
141
153
 
142
154
  return server;
143
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {