umtri-mcp 1.1.0 → 1.2.1

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
@@ -57,9 +57,10 @@ appear and then error, re-check the token and its scope — `read` tokens cannot
57
57
  `list_events`
58
58
 
59
59
  **Write** — `create_project`, `create_node`, `update_node`, `delete_node`, `create_edge`,
60
- `delete_edge`, `create_api`, `update_api`, `delete_api`, `create_bug`, `update_bug`, `delete_bug`
60
+ `delete_edge`, `create_api`, `update_api`, `delete_api`, `create_bug`, `update_bug`, `delete_bug`,
61
+ `create_season` (acts only after the user confirms)
61
62
 
62
- **Plan loop** — `commit_plan`, `record_commit`, `reopen_transplant`
63
+ **Plan loop** — `commit_plan`, `record_commit`
63
64
 
64
65
  `get_graph` returns a slice, not a dump: scope by subtree (`rootId`), by layer (`maxType`), by role,
65
66
  or by season, and control description weight separately. A large tree stays cheap to read.
@@ -73,7 +74,7 @@ nodes are meant to be realized, and how a ground behaves while transplanting.
73
74
  ```
74
75
  umtri://rules/vocabulary umtri://rules/transplant
75
76
  umtri://rules/vocabulary-detailed umtri://rules/plan
76
- umtri://rules/seasons-human-only umtri://about/vision
77
+ umtri://rules/seasons umtri://about/vision
77
78
  umtri://rules/system-structure
78
79
  ```
79
80
 
@@ -36,7 +36,12 @@ export function createApiClient({ baseUrl, getToken }) {
36
36
  if (!res.ok) {
37
37
  const text = await res.text().catch(() => '');
38
38
  if (res.status === 403) {
39
- throw new Error(`${method} ${path} → 403: token lacks write scope. Generate a 'write' token in Settings → API Tokens.`);
39
+ // 403은 한 가지가 아니다 — write scope 없음과 admin 전용 경로(feedback 관리 등)가 같이 온다.
40
+ // 서버가 준 이유를 버리고 토큰 안내로 덮으면, 관리자 경로에서 막힌 사람이 멀쩡한 토큰을
41
+ // 다시 발급하러 간다. 이유를 그대로 싣고, 스코프 문제일 때만 발급 안내를 덧붙인다.
42
+ const reason = text.slice(0, 200) || 'forbidden';
43
+ const hint = /scope/i.test(text) ? " Generate a 'write' token in Settings → API Tokens." : '';
44
+ throw new Error(`${method} ${path} → 403: ${reason}${hint}`);
40
45
  }
41
46
  throw new Error(`${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
42
47
  }
@@ -4,9 +4,17 @@
4
4
  // 그래서 여기엔 "언젠가 읽으면 좋은 것"이 아니라 **읽지 않으면 조용히 어긋나는 것**만
5
5
  // 둔다. 매 세션 컨텍스트에 실리므로 길어지면 그 자체가 비용이고, 길수록 안 읽힌다.
6
6
  // 상세는 전부 umtri://rules/* 리소스로 넘긴다.
7
+ //
8
+ // send_feedback 문단이 여기 있는 건 그 기준의 예외처럼 보이지만 아니다. 도구 목록에
9
+ // 이름이 있어도 모델은 요청받은 일만 하므로, 이 한 줄이 없으면 도구는 있고 아무도
10
+ // 부르지 않는 상태로 남는다. 마찰을 가장 먼저 겪는 건 에이전트인데 그 관찰은 세션이
11
+ // 끝나면 사라진다. "사람에게 권한다"까지가 문단의 핵심이다 — 자율 발송을 허용하면
12
+ // 접수함이 에이전트의 불평으로 먼저 죽는다.
7
13
 
8
14
  export const SERVER_INSTRUCTIONS = `Umtri maps a project's structure as a tree (trunk → limb → twig → leaf → vein) and tracks how it changes over seasons. Start with list_projects, then get_graph.
9
15
 
10
16
  Umtri never reads git. A commit reaches the ground only because something called create_node / update_node / record_commit — so the tree drifts behind the code unless the repo has a habit that says otherwise. If this repo's own rules file (CLAUDE.md, AGENTS.md, …) has no commit-sync policy, read umtri://rules/commit-sync and offer the human a short one to paste in.
11
17
 
12
- Before creating or reclassifying nodes, read umtri://rules/vocabulary. Seasons are human-only (umtri://rules/seasons-human-only).`;
18
+ Before creating or reclassifying nodes, read umtri://rules/vocabulary. Opening a season seals the current one, so create_season only acts after the user confirms (umtri://rules/seasons).
19
+
20
+ If Umtri itself gets in your way — a call rejected for a reason that looks wrong, a capability that is missing, a tool description that misled you — tell the human what happened and offer to file it with send_feedback. Never file it on your own initiative.`;
package/core/policy.js CHANGED
@@ -231,6 +231,93 @@ export function reparentMetadataHint(patch) {
231
231
  };
232
232
  }
233
233
 
234
+ // ── wiki 본문 ────────────────────────────────────────────────────────
235
+ // 위키를 로그로 쓰는 경향이 실제로 관찰됐다 — 작업을 끝낸 에이전트가 페이지를 열어
236
+ // "이번에 뭘 했는지"를 덧붙이고, 몇 번 반복되면 페이지가 세션 요약 더미가 된다.
237
+ // 규칙 문서(umtri://rules/wiki)에 적어뒀지만 리소스는 pull-only라 안 읽으면 그만이라,
238
+ // 쓰는 순간에 신호를 준다.
239
+ //
240
+ // **거부하지 않고 경고만 한다.** 판정이 휴리스틱이고, 날짜가 정당하게 필요한 문서도
241
+ // 있기 때문이다(마이그레이션 위험 고지 등). 오탐이 잦으면 경고 자체가 무시되므로
242
+ // 정밀도가 높은 신호만 고른다 — 셋 다 산문형 레퍼런스에는 거의 나오지 않는 형태다.
243
+ // 날짜가 제목 **앞머리**에 올 때만 잡는다. "## 2026-08-27 — 인증 분리"는 일지 항목이지만
244
+ // "## 확정된 결정 (2026-07-28)"은 결정에 날짜를 단 것이라 정당하다. 괄호 안 날짜까지
245
+ // 잡으면 오탐이 늘고, 오탐이 늘면 경고를 아무도 안 본다.
246
+ const WIKI_DATE_HEADING_RE = /^#{1,6}\s*[([]?\s*20\d{2}\s*[-./년]\s*\d{1,2}\s*[-./월]\s*\d{1,2}/m;
247
+ const WIKI_CHANGELOG_HEADING_RE = /^#{1,6}\s*(?:.*(?:change\s*(?:log|history)|revision history|release notes|변경\s*(?:이력|내역)|작업\s*이력|업데이트\s*내역|진행\s*(?:표시|상황)).*)$/im;
248
+ const WIKI_CHECKBOX_RE = /^\s*[-*+]\s*\[[ xX]\]/gm;
249
+
250
+ export function wikiLogShapeCheck(body) {
251
+ if (!body || typeof body !== 'string') return [];
252
+ const out = [];
253
+
254
+ if (WIKI_DATE_HEADING_RE.test(body)) {
255
+ out.push({
256
+ rule: 'wiki-dated-entry',
257
+ severity: 'warn',
258
+ message: 'This body has a date heading. A wiki page is a description of how something works now, not a dated log — the page\'s own past is kept in its revisions (list_wiki_revisions) and who-changed-what in list_events. Fold the fact into the prose and drop the date, unless the date is itself a live hazard a reader must know.',
259
+ });
260
+ }
261
+
262
+ if (WIKI_CHANGELOG_HEADING_RE.test(body)) {
263
+ out.push({
264
+ rule: 'wiki-changelog-section',
265
+ severity: 'warn',
266
+ message: 'This body has a change-history / progress section. Revisions already record what the page said before, so that section duplicates them and goes stale. Delete it and let the prose describe the current state.',
267
+ });
268
+ }
269
+
270
+ const boxes = body.match(WIKI_CHECKBOX_RE);
271
+ if (boxes && boxes.length >= 3) {
272
+ out.push({
273
+ rule: 'wiki-progress-checklist',
274
+ severity: 'info',
275
+ message: `This body has ${boxes.length} checklist items. Outstanding work belongs in bugs (create_bug) or plan nodes, where it can be tracked and closed — a checklist in a wiki page is read by nobody and rots. Keep the page to what is true now.`,
276
+ });
277
+ }
278
+
279
+ return out;
280
+ }
281
+
282
+ // ── 도구 호출 마크업 유출 ────────────────────────────────────────────
283
+ // 인자가 필드로 쪼개지지 못하고 첫 필드에 통째로 들어오는 사고가 실제로 있었다
284
+ // (feedback #1: create_bug을 세 번 부르는 동안 세 번 다 solution이 description 꼬리에
285
+ // 문자열로 붙고 solution은 null로 저장됐다). 긴 산문 필드 둘이 나란히 오면 특히 잦다.
286
+ //
287
+ // 저장 자체는 성공하므로 아무도 모른 채 지나가고, 사람이 앱에서 볼 때에야 드러난다.
288
+ // 그래서 쓰기 응답에 그 자리에서 신호를 준다.
289
+ //
290
+ // **거부하지 않고 경고만 한다.** 도구 포맷을 *설명하는* 산문이 이 조각들을 정상적으로
291
+ // 담기 때문이다 — 하필 이 프로젝트의 위키가 그런 글을 쓴다. 대신 코드펜스와 인라인
292
+ // 코드를 걷어내고 검사한다: 설명하는 글은 따옴표 안에 넣고, 진짜 유출은 맨몸으로 온다.
293
+ // 이 구분이 오탐을 거의 없앤다.
294
+ const TOOL_MARKUP_RES = [
295
+ /<\/?(?:antml:)?parameter\b/i,
296
+ /<\/?(?:antml:)?invoke\b/i,
297
+ /<\/?(?:antml:)?function_calls\b/i,
298
+ /<\/(?:description|solution|body|title|label|note|metadata)>/i,
299
+ ];
300
+
301
+ function withoutCode(text) {
302
+ return text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`]*`/g, ' ');
303
+ }
304
+
305
+ // fields: { 필드이름: 값 } — 문자열이 아닌 값은 건너뛴다.
306
+ export function toolMarkupCheck(fields) {
307
+ const out = [];
308
+ for (const [name, value] of Object.entries(fields || {})) {
309
+ if (!value || typeof value !== 'string') continue;
310
+ const bare = withoutCode(value);
311
+ if (!TOOL_MARKUP_RES.some((re) => re.test(bare))) continue;
312
+ out.push({
313
+ rule: 'tool-markup-leaked',
314
+ severity: 'warn',
315
+ message: `The "${name}" value contains raw tool-call markup (e.g. "</${name}>" or "<parameter name=...>"). That means this call's arguments were not split into fields: the text meant for the next parameter was appended to this one, and that parameter was almost certainly saved empty. Read the record back and re-send the values as separate arguments — nothing else will report this. (Writing *about* tool-call syntax is fine: put it in a code fence or inline code, which this check ignores.)`,
316
+ });
317
+ }
318
+ return out;
319
+ }
320
+
234
321
  // ── bug lifecycle ────────────────────────────────────────────────────
235
322
  // Bug Codex reads status as three states: open=wild, in_progress=chasing,
236
323
  // resolved|closed=catched. The recommended path is wild → chasing → catched:
@@ -41,7 +41,7 @@ back onto the nodes. The human commits when satisfied.
41
41
  ## Boundaries
42
42
 
43
43
  - Plan is independent of seasons; you still cannot create or modify seasons
44
- (see umtri://rules/seasons-human-only).
44
+ (see umtri://rules/seasons).
45
45
  - The brief is still an information-structure diagram, not a work log
46
46
  (see umtri://rules/vocabulary) — realize the structure the human intends,
47
47
  and name nodes as what they are.
@@ -0,0 +1,47 @@
1
+ # Rule: Seasons Need the Human's Word
2
+
3
+ A `season` is a deliberate growth epoch — "Q3 2026", "v2 redesign", "PoC".
4
+ It is the unit by which a project owner *consciously breaks time* in their
5
+ ground. You may create one, but never on your own judgment.
6
+
7
+ ## Why this needs confirming
8
+
9
+ Creating a season is not additive. The ground's current `now` season is sealed
10
+ as `past` and every node in it is marked grown, which locks that node's
11
+ `parent`, `season`, `type` and `sproutedAt`. A season opened because you could
12
+ not decide where a node belonged turns the ground's timeline into noise, and
13
+ the lock it leaves behind is not yours to undo.
14
+
15
+ ## The gate
16
+
17
+ `create_season` refuses to create anything until the human has agreed.
18
+
19
+ 1. Call it with `slug` and `label` and **no** `confirm`. Nothing is created;
20
+ you get back the label you proposed and the season that would be sealed.
21
+ 2. Put that to the user in your own words — the new season and what closes.
22
+ 3. Only once they say yes, call again with `confirm: true`.
23
+
24
+ Passing `confirm: true` on the first call is a protocol violation, even when
25
+ the user's request seems to imply it. "Move the pre-April work into a PoC
26
+ season" names a season; it does not confirm sealing the current one.
27
+
28
+ ## When not to reach for it at all
29
+
30
+ Unsure which season a new node belongs to? Do **not** open one.
31
+
32
+ - Attach it to the existing season whose state is `now`.
33
+ - If no `now` season exists, ask the user which season to attach to.
34
+
35
+ A missing season is a question for the human, not a gap for you to fill.
36
+
37
+ ## What needs no confirmation
38
+
39
+ - Reading seasons (`list_seasons`).
40
+ - Attaching new nodes to existing seasons via the `season` field.
41
+ - Suggesting that a new season *might* be useful, and waiting.
42
+
43
+ ## Still human-only
44
+
45
+ Rooting a ground (ending transplant) stays human-only through the UI, and once
46
+ rooted it cannot be re-opened by anyone but an administrator.
47
+ See umtri://rules/transplant.
@@ -23,22 +23,26 @@ You MAY:
23
23
  `hard` only to clean up genuine mistakes — it is irreversible.
24
24
 
25
25
  You MAY NOT:
26
- - **Create or modify seasons.** Seasons are always human-only
27
- (see umtri://rules/seasons-human-only). The human lays down the historical
28
- seasons; you fill nodes into them.
26
+ - **Open a season without asking.** `create_season` exists, but it only acts
27
+ after the user confirms (see umtri://rules/seasons). Laying down historical
28
+ seasons is exactly the case where you propose and they decide.
29
29
  - **Root (settle) a ground.** Ending transplant is human-only — done from the
30
30
  UI ("Root this ground"). You cannot root via MCP.
31
31
 
32
- ## Re-opening transplant (`reopen_transplant`)
32
+ ## Rooting is final
33
33
 
34
- A rooted ground can be put back into transplant with the `reopen_transplant`
35
- tool — but **only when the user explicitly asks for it** (e.g. "switch this
36
- ground to transplanting", "옮겨심기로 바꿔줘"). This removes safety guardrails,
37
- so you must **never** decide to re-open on your own initiative, however
38
- convenient it would be for a restructuring task. No explicit request → do not
39
- call it; instead tell the user the ground is rooted and ask whether they want
40
- to re-open transplant. After re-opening, the user roots it again from the UI
41
- when the work is done.
34
+ Once a ground is rooted, **nothing re-opens transplant** — not you, not the
35
+ person who owns the ground. There is no `reopen_transplant` tool, and
36
+ `POST /api/projects/:slug/root` refuses `rooted: false`. Only an administrator
37
+ can re-open it, out of band.
38
+
39
+ This is deliberate. Re-opening unlocks every past season at once, so a window
40
+ meant for the first import would otherwise become the standard way around a
41
+ guard. If a rooted ground genuinely needs restructuring, say so plainly and
42
+ let the human decide whether to take it to an administrator.
43
+
44
+ Most of what people reach for transplanting to do does not need it — a stale
45
+ `metadata.implements` on a past node is editable as it stands (see below).
42
46
 
43
47
  ## Audit stamp
44
48
 
@@ -43,4 +43,4 @@ When you (an AI tool) work on an Umtri ground:
43
43
  touched (`metadata.implements`, `metadata.commits`) via the API — e.g. a
44
44
  GitHub Action calling `record_commit` on merge. Umtri stores the form; it
45
45
  does not execute the pipeline.
46
- - It does not create seasons (see `umtri://rules/seasons-human-only`).
46
+ - It does not open a season on its own judgment (see `umtri://rules/seasons`).
@@ -98,7 +98,7 @@ nodes (in `type` or `label`) is the most common mistake.
98
98
 
99
99
  - Bug / error / issue → `bugs` table — use `create_bug`
100
100
  - API / endpoint / integration → `apis` table — use `create_api`
101
- - Season / sprint / epoch → human-only, see `umtri://rules/seasons-human-only`
101
+ - Season / sprint / epoch → needs the user's confirmation, see `umtri://rules/seasons`
102
102
  - Dependency / data flow → `edges` table — use `create_edge`
103
103
 
104
104
  ## Bug lifecycle — one step at a time
@@ -0,0 +1,210 @@
1
+ # Writing a Ground's Wiki
2
+
3
+ The tree holds structure. The wiki holds what the tree cannot: the conventions a
4
+ project settled on, the decisions it made and why, the vocabulary it uses, what
5
+ an investigation concluded.
6
+
7
+ Two things to remember. The first is the shape of a page:
8
+
9
+ > **A page is named after a thing, and its revisions are that thing's life.**
10
+ > Revision 1 is the plan. Later revisions are what it became. The newest
11
+ > revision is what is true now.
12
+
13
+ The second is what a page *is*:
14
+
15
+ > **A page is a description, not a log.**
16
+ > It tells a reader how something works right now. It does not narrate what
17
+ > happened to it.
18
+
19
+ Everything below follows from those two.
20
+
21
+ ## Write reference, not a diary
22
+
23
+ This is the mistake that actually happens. An agent finishes some work, opens
24
+ the page, and appends a paragraph about what it just did. Do that a few times
25
+ and the page is a pile of session summaries that nobody can read as an
26
+ explanation of anything.
27
+
28
+ **The page should read like an encyclopedia entry for the thing, in the present
29
+ tense.** Someone who arrives knowing nothing should finish it understanding how
30
+ the thing works. They should not have to reconstruct the current state by
31
+ replaying a timeline.
32
+
33
+ | Log — do not write this | Description — write this |
34
+ | --- | --- |
35
+ | "Added retry logic to the webhook handler today." | "The webhook handler retries three times with backoff. Toss redelivers on any non-2xx, so a slow database would otherwise double-charge." |
36
+ | "## 2026-08-27 — split the auth middleware" | "Auth runs in two stages: session or bearer resolution, then scope check. They are separate because MCP loopback needs the second without the first." |
37
+ | "Phase 1 done, Phase 2 in progress" | (belongs in bugs or plan nodes, not here) |
38
+ | "## Change history" / "## 변경 이력" | (delete it — that is what revisions are for) |
39
+
40
+ Concretely, a page should not contain:
41
+
42
+ - **Date headings** or dated entries of any kind
43
+ - **Progress checklists** — "Phase 2 완료", "- [x] W3", "TODO: …"
44
+ - **Sentences about the act of working** — "we added", "이번에 …했다", "next I will"
45
+ - **A changelog section**
46
+
47
+ ### Why this is not a loss
48
+
49
+ The temporal record already exists, in better places:
50
+
51
+ | What you want to record | Where it belongs |
52
+ | --- | --- |
53
+ | What this page said before | revisions (`list_wiki_revisions`) |
54
+ | Who changed what, when | the ground's history (`list_events`) |
55
+ | Which commit touched which code | `record_commit` → the node's `metadata.commits` |
56
+ | What still needs doing | bugs (`create_bug`) or plan nodes |
57
+ | What you plan to do | revision 1 of the page, written *before* you build |
58
+
59
+ **Because the history lives in the revisions, the body is free to be purely
60
+ present-tense.** That is the whole point of versioning the page: you never have
61
+ to keep an old paragraph around just to preserve the record of it. Rewrite the
62
+ sentence to say what is true now, and the previous wording stays readable in the
63
+ revision underneath.
64
+
65
+ A page that logs is doing, badly, a job four other things already do well — and
66
+ failing at the one job only it can do.
67
+
68
+ ### The exception: what "why" survives
69
+
70
+ Present tense does not mean stripping the reasoning. "The webhook handler
71
+ retries because Toss redelivers on any non-2xx" is present tense and is exactly
72
+ the sentence worth keeping. The test is not *is this about the past* but **would
73
+ someone changing this code get it wrong without knowing it** — see "What stays
74
+ on the page" below.
75
+
76
+ Past events belong in a page only when the event is *itself* a live constraint:
77
+ "the `apex` type was renamed to `leaf` in 2026-05; old exports may still carry
78
+ it." That is not a diary entry, it is a hazard a reader needs.
79
+
80
+ ## Name the thing, not the document
81
+
82
+ A page is called `mcp`, `billing`, `impact-analysis` — the subject it is about.
83
+
84
+ Never name a page after what it currently *is*: `prd-mcp`, `mcp-spec`,
85
+ `mcp-notes-v2`, `mcp-final`. Those names are accurate for a week. The page that
86
+ begins life as a plan for MCP and ends as the description of the MCP you built
87
+ is the same page the whole time, and `prd-mcp` becomes a lie the moment the
88
+ first line of it ships.
89
+
90
+ Use lowercase letters, digits and hyphens. Say what a maintainer would say out
91
+ loud: `deploy`, not `deployment-process-documentation`.
92
+
93
+ If a name turns out wrong, use `write_wiki`'s **`rename`**. Do not create a new
94
+ page and delete the old one — that throws away every revision, which is the part
95
+ of the page that was worth keeping. A rename on its own adds no revision; a
96
+ revision records content, and the name is not content.
97
+
98
+ ## Revision 1 is the plan
99
+
100
+ Before building something, write the page. What you are about to do, why this
101
+ way, what you decided against. That is revision 1, and it is a real deliverable:
102
+ another agent picking up the work reads it instead of guessing.
103
+
104
+ This replaces keeping design docs in the repo. A plan in a repo file goes stale
105
+ silently — nothing makes anyone revisit it, and six months later no one can tell
106
+ whether it describes the code or someone's old intention. A plan that is
107
+ revision 1 of a living page cannot rot the same way, because the page keeps
108
+ being updated and the plan stays readable underneath as history.
109
+
110
+ Plan nodes and this page do different jobs and work together: the plan nodes say
111
+ *where* in the structure something goes, the page says *what and why*. See
112
+ `umtri://rules/plan`.
113
+
114
+ ## Write back what you actually built — with `newRevision: true`
115
+
116
+ When the thing exists, come back and make the page describe it. Not "the plan
117
+ plus a note that it's done" — the page should now read as a description of what
118
+ is there.
119
+
120
+ **Pass `newRevision: true` on that write.** This matters more than it looks.
121
+ Writes fold together when they come from the same author within about half an
122
+ hour, so that eight passes of polishing leave one revision instead of eight.
123
+ That folding cannot tell "still drafting the plan" from "the plan has become the
124
+ thing" — clock and author look identical in both cases. Without the flag, an
125
+ agent that plans and then builds in one sitting overwrites its own plan, and
126
+ revision 1 is gone.
127
+
128
+ Use it whenever the page crosses a boundary: plan → built, v1 → v2, one approach
129
+ abandoned for another. Skip it while you are still working on the same stage.
130
+
131
+ ## What stays on the page, what becomes history
132
+
133
+ The page is the current state. But "current state" is not just the mechanics —
134
+ **a live reason is part of the current spec.**
135
+
136
+ | Stays on the page | Becomes history |
137
+ | --- | --- |
138
+ | How it works now | How it used to work |
139
+ | The constraint that makes it work this way | A constraint that no longer applies |
140
+ | A decision you would repeat, and why | The full argument that led to a reversed decision |
141
+ | The conclusion of a reversal: "we tried A, moved to B, because —" | The prose written while A was still true |
142
+
143
+ The test: **would someone about to change this code get it wrong without
144
+ knowing this?** If yes, it belongs on the page, however old it is.
145
+
146
+ A rule like "team org keeps `plan='free'` because plan describes that org's own
147
+ subscription and a team org has none" is not history. It is a live trap, and a
148
+ page that files it away as an old decision will get rewritten wrong.
149
+
150
+ A page that strips out every reason becomes a manual, and a manual is exactly
151
+ the thing the tree could have held. Do not optimize the wiki into uselessness.
152
+
153
+ ## One page per thing
154
+
155
+ Split when a page starts answering two questions a reader would arrive with
156
+ separately. Merge — or never split — when the answer to one requires the other.
157
+
158
+ Do not split by document type. `mcp` and `mcp-architecture` are not two pages;
159
+ they are one page at two points in its life. Do not create a page per file, per
160
+ sprint, or per meeting. A ground with forty one-paragraph pages is harder to
161
+ read than the code.
162
+
163
+ ## Choosing how to write
164
+
165
+ | | |
166
+ | --- | --- |
167
+ | The thing changed | `mode="replace"` — **read the page first** (`get_wiki`) |
168
+ | The page is missing a whole topic you can add as its own section | `mode="append"` |
169
+ | Crossing into a new stage | add `newRevision: true` |
170
+ | A write went wrong | `list_wiki_revisions`, then `mode="restore"` with the rev |
171
+ | You read, thought, then wrote, and someone else may have written meanwhile | add `baseRev` — the write is refused instead of clobbering |
172
+
173
+ **`append` is the narrow case, not the default.** It is for adding a section
174
+ that was genuinely absent — a new subsystem the page never covered. It is not
175
+ for tacking on what you just did. If your addition would only make sense to
176
+ someone who knows what happened this week, you want `replace`: fold the new fact
177
+ into the sentence that was already there.
178
+
179
+ After any write, look at what came back. `previousBodyChars` is how long the
180
+ body was before you wrote. If it is much larger than what you just wrote, you
181
+ replaced someone's work — check `list_wiki_revisions` before moving on. Nothing
182
+ is lost, but nobody will tell you later.
183
+
184
+ ## Attaching to nodes
185
+
186
+ A page can hang off a node or float free. Floating is normal and expected early:
187
+ knowledge arrives before structure, and a page that has to wait for a node is a
188
+ page that never gets written.
189
+
190
+ Once the tree has grown, list the unattached pages (`list_wiki` with
191
+ `node="none"`) and attach the ones that now have a home. Deleting a node
192
+ detaches its pages rather than deleting them.
193
+
194
+ ## Deleting
195
+
196
+ Almost never. Deleting removes the page **and its entire revision history** —
197
+ unlike an overwrite, it cannot be undone.
198
+
199
+ A stale page is not a reason to delete. Rewrite it, and let the old state live
200
+ in its revisions. Delete only a page that should never have existed: a
201
+ duplicate, a test, something filed under the wrong ground.
202
+
203
+ ## What does not belong here
204
+
205
+ - **Structure** — that is what nodes are for. Do not maintain a prose copy of
206
+ the tree; it will drift within a week.
207
+ - **Defects** — use bugs. A wiki page is not a to-do list.
208
+ - **Requirements dumped in a ground's description** — that field is a one-line
209
+ summary, not a spec. Move it here.
210
+ - **Secrets** — tokens, keys, passwords. The wiki is prose, not a vault.
package/core/resources.js CHANGED
@@ -23,11 +23,11 @@ const RESOURCES = [
23
23
  description: 'Reclassification policy, twig vs leaf decision matrix, twig promotion signals, redundant-child antipattern, Korean label heuristics. Read only when restructuring an existing tree or wrestling with a borderline classification.',
24
24
  },
25
25
  {
26
- name: 'seasons-human-only',
27
- uri: 'umtri://rules/seasons-human-only',
28
- file: 'seasons-human-only.md',
29
- title: 'Seasons are human-only',
30
- description: 'AI tools must not create seasons. Defaults and exceptions explained.',
26
+ name: 'seasons',
27
+ uri: 'umtri://rules/seasons',
28
+ file: 'seasons.md',
29
+ title: 'Seasons need the human\'s word',
30
+ description: 'create_season exists but is gated: the first call only reports what would be sealed, and creation needs confirm:true after the user agrees. Read before opening a season, or when unsure which season a node belongs to.',
31
31
  },
32
32
  {
33
33
  name: 'transplant',
@@ -50,6 +50,13 @@ const RESOURCES = [
50
50
  title: 'Keep the ground in step with commits',
51
51
  description: 'Umtri never reads git — nodes drift unless something records the change. Recommends writing a short commit-sync policy into the repo\'s own agent rules file (CLAUDE.md/AGENTS.md), with a snippet to adapt. Read when setting Umtri up in a repo, or when a commit turns out to have left the tree behind.',
52
52
  },
53
+ {
54
+ name: 'wiki',
55
+ uri: 'umtri://rules/wiki',
56
+ file: 'wiki.md',
57
+ title: 'How to write a ground\'s wiki',
58
+ description: 'A wiki page is named after a thing, and its revisions are that thing\'s life: rev 1 is the plan, later revisions are what it became, the newest is what is true now. Covers naming (the subject, never "prd-x"), writing the plan first, marking the plan→built boundary with newRevision, what stays on the page versus what becomes history (a live reason is current spec, not history), when to split pages, replace/append/restore/baseRev, attaching to nodes, and why deleting is almost never right. Read before writing or reorganizing wiki pages.',
59
+ },
53
60
  {
54
61
  name: 'system-structure',
55
62
  uri: 'umtri://rules/system-structure',
package/core/tools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // - api: createApiClient(...) 결과 — get/post/put/patch/delete
6
6
 
7
7
  import { z } from 'zod';
8
- import { validateNode, validateEdge, validateUpdate, validateBugUpdate } from './policy.js';
8
+ import { validateNode, validateEdge, validateUpdate, validateBugUpdate, wikiLogShapeCheck, toolMarkupCheck } from './policy.js';
9
9
 
10
10
  // LLM이 소비하는 결과 — 들여쓰기는 순수 토큰 낭비라 minify. (큰 그래프에서 ~19% 절감)
11
11
  function jsonResult(data) {
@@ -153,7 +153,7 @@ function compressGraph(graph, { descMode = 'excerpt' } = {}) {
153
153
  }
154
154
 
155
155
  // list_projects summary — 슬라이드 식별·탐색에 필요한 핵심만. icon base64(metadata)·
156
- // preNotes·seedMeta·typeCounts 등 무거운 필드는 생략. description은 descMode로 제어.
156
+ // seedMeta·typeCounts 등 무거운 필드는 생략. description은 descMode로 제어.
157
157
  function compressProjects(projects, { descMode = 'excerpt' } = {}) {
158
158
  if (!Array.isArray(projects)) return projects;
159
159
  return projects.map((p) => {
@@ -166,6 +166,8 @@ function compressProjects(projects, { descMode = 'excerpt' } = {}) {
166
166
  openBugs: p.openBugs,
167
167
  seasonCount: p.seasonCount,
168
168
  };
169
+ // 문서가 있다는 사실만 흘린다. 0이면 생략 — 대부분의 ground가 0이라 매번 실으면 잡음이다.
170
+ if (p.wikiCount) out.wikiCount = p.wikiCount;
169
171
  if (p.nowSeasonLabel) out.nowSeasonLabel = p.nowSeasonLabel;
170
172
  if (p.latestActivityAt) out.latestActivityAt = p.latestActivityAt;
171
173
  const d = pickDesc(p.description, descMode);
@@ -187,6 +189,8 @@ function compressBugs(bugs, { descMode = 'excerpt' } = {}) {
187
189
  status: b.status,
188
190
  };
189
191
  if (b.impactCount != null) out.impactCount = b.impactCount;
192
+ // 체크리스트 진행도 — "남은 일이 몇 개냐"에 본문을 열 필요가 없게. 항목이 없으면 생략.
193
+ if (b.solutionProgress) out.solutionProgress = `${b.solutionProgress.done}/${b.solutionProgress.total}`;
190
194
  if (b.createdAt) out.createdAt = b.createdAt;
191
195
  if (b.resolvedAt) out.resolvedAt = b.resolvedAt;
192
196
  const d = pickDesc(b.description, descMode);
@@ -314,7 +318,7 @@ export function registerTools(server, { api }) {
314
318
  'list_projects',
315
319
  {
316
320
  title: 'List grounds (projects)',
317
- description: 'Returns all grounds the authenticated user can access, with latest activity timestamp. Use this first to discover slugs for other tools. By default returns a summary view — each ground carries slug, name, isActive, transplanting, nodeCount, openBugs, seasonCount, nowSeasonLabel, latestActivityAt, plus a 200-char description excerpt. Pass view="full" for all fields (preNotes, seedMeta, typeCounts, raw metadata, timestamps); the icon base64 in metadata is always stripped (UI-only).',
321
+ description: 'Returns all grounds the authenticated user can access, with latest activity timestamp. Use this first to discover slugs for other tools. By default returns a summary view — each ground carries slug, name, isActive, transplanting, nodeCount, openBugs, seasonCount, nowSeasonLabel, latestActivityAt, plus a 200-char description excerpt. A ground with wiki pages also carries wikiCount — prose the tree cannot hold (conventions, decisions, glossary); read it with list_wiki. Pass view="full" for all fields (seedMeta — the ground\'s human-written intent: goal, endDate, budget, audience — plus typeCounts, raw metadata, timestamps); the icon base64 in metadata is always stripped (UI-only).',
318
322
  inputSchema: z.object({
319
323
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for every field.'),
320
324
  }),
@@ -334,7 +338,7 @@ export function registerTools(server, { api }) {
334
338
  'get_graph',
335
339
  {
336
340
  title: 'Get the graph of a ground',
337
- description: 'Returns the tree(nodes), edges, apis, seasons, and bugs for one ground. The tree is the project\'s information-structure (IA) diagram — each node is a structural element (an object, or an action it performs), not a work-log entry. Node types follow the plant metaphor: trunk → limb → twig → leaf → vein. See resource umtri://rules/vocabulary. By default returns a summary view — nodes carry id/label/type/parent/season, plus a 200-char description excerpt, metadata.implements, placeholder/dormant flags (soft-deleted nodes are omitted). Pass view="full" only when you need timestamps, full descriptions, all metadata keys, or tags — that response is ~3× larger. For a large tree, read it in slices instead of all at once: rootId (+depth) for one trunk or limb; maxType for a layer (e.g. maxType="twig" = the skeleton without leaves/veins — a cheap overview); role for a cross-section; season for what was born in one season. Filters combine. Node ids come from the graph itself, so the standard drill-down is two calls: first get_graph with maxType="trunk" (or "limb") for a cheap skeleton, find the id you want, then call again with rootId set to it to get just that trunk/limb and its subtree. Control the heaviest field with descriptions: "none" drops descriptions for a pure structural overview, "excerpt" (default) gives a 200-char preview, "full" returns them verbatim when you drill into a limb. The summary response also carries shape (nodeCount, maxDepth, nodes-per-level, over-wide branches) so you can judge whether the tree is too deep or too wide without rebuilding it, a childCount on each branch node, and iaHints flagging structural smells. A bushy tree (mass at mid-levels) is healthy — don\'t over-nest sparse parents into twigs. Bugs default to active (open + in_progress) — pass bugStatus="all" to also see healed (resolved/closed) ones. Nodes may carry plan:true — these are the human\'s node-based brief (intent drawn as structure, not a prompt); read them as instructions and realize them (see umtri://rules/plan). When you slice (rootId/maxType/role/season), a connection with only ONE endpoint inside the slice is still returned, marked boundary:true, and its outside endpoint appears as a lightweight stub in externalNodes (id/label/type/role, external:true) — so cross-branch dependencies and calls never silently vanish from a slice. To follow one, call get_graph again with rootId set to that external id. The response also carries project.transplanting — when true the ground is still being transplanted (see umtri://rules/transplant): you may freely add/edit nodes in any season incl. past, and hard-delete import mistakes.',
341
+ description: 'Returns the tree(nodes), edges, apis, seasons, and bugs for one ground. The tree is the project\'s information-structure (IA) diagram — each node is a structural element (an object, or an action it performs), not a work-log entry. Node types follow the plant metaphor: trunk → limb → twig → leaf → vein. See resource umtri://rules/vocabulary. By default returns a summary view — nodes carry id/label/type/parent/season, plus a 200-char description excerpt, metadata.implements, placeholder/dormant flags (soft-deleted nodes are omitted). Pass view="full" only when you need timestamps, full descriptions, all metadata keys, or tags — that response is ~3× larger. For a large tree, read it in slices instead of all at once: rootId (+depth) for one trunk or limb; maxType for a layer (e.g. maxType="twig" = the skeleton without leaves/veins — a cheap overview); role for a cross-section; season for what was born in one season. Filters combine. Node ids come from the graph itself, so the standard drill-down is two calls: first get_graph with maxType="trunk" (or "limb") for a cheap skeleton, find the id you want, then call again with rootId set to it to get just that trunk/limb and its subtree. Control the heaviest field with descriptions: "none" drops descriptions for a pure structural overview, "excerpt" (default) gives a 200-char preview, "full" returns them verbatim when you drill into a limb. The summary response also carries shape (nodeCount, maxDepth, nodes-per-level, over-wide branches) so you can judge whether the tree is too deep or too wide without rebuilding it, a childCount on each branch node, and iaHints flagging structural smells. A bushy tree (mass at mid-levels) is healthy — don\'t over-nest sparse parents into twigs. Bugs default to active (open + in_progress) — pass bugStatus="all" to also see healed (resolved/closed) ones. Nodes may carry plan:true — these are the human\'s node-based brief (intent drawn as structure, not a prompt); read them as instructions and realize them (see umtri://rules/plan). When you slice (rootId/maxType/role/season), a connection with only ONE endpoint inside the slice is still returned, marked boundary:true, and its outside endpoint appears as a lightweight stub in externalNodes (id/label/type/role, external:true) — so cross-branch dependencies and calls never silently vanish from a slice. To follow one, call get_graph again with rootId set to that external id. The response also carries project.transplanting — when true the ground is still being transplanted (see umtri://rules/transplant): you may freely add/edit nodes in any season incl. past, and hard-delete import mistakes. project.seedMeta is the ground\'s intent when the human filled it in — goal, endDate, budget, audience. No tool writes it, so it is the human\'s own brief for what this tree is for: read it before you propose or reshape structure, and let it settle what belongs in the tree and what does not.',
338
342
  inputSchema: z.object({
339
343
  slug: z.string().min(1).describe('Ground slug (from list_projects).'),
340
344
  view: z.enum(['summary', 'full']).optional().describe('summary (default) for compact nodes; full for raw shape with all fields.'),
@@ -404,7 +408,7 @@ export function registerTools(server, { api }) {
404
408
  'list_bugs',
405
409
  {
406
410
  title: 'List bugs of a ground',
407
- description: 'Scans the bugs (issues eroding the ground) of one ground. Looking at ONE bug you already know the number/id of? Call get_bug instead — no need to list. Bugs default to status="active" (wild + chasing): a healed bug is history, and history is not what a scan is for. Ask for "catched" or "all" when you actually want it. Status speaks the product\'s own words — wild (found, nobody on it) → chasing (someone is fixing it) → catched (it landed) — the same words the UI shows. Each bug has a score (0–8 change-risk; 8 = riskiest). Targets: a node, an api, or the ground itself. By default returns a summary view — each bug carries id, seq (the number a human says, "#14"), target, title, score, status, createdAt/resolvedAt, plus a 200-char description excerpt. Each node/api bug also carries impactCount — how many other nodes its target reaches by blast radius (affected direction), so you can spot wide-blast bugs at a glance; call get_impact on that bug for the full reached list. Pass view="full" for full descriptions and metadata. Use limit + order to bound a scan (e.g. the 5 newest) instead of pulling every bug.',
411
+ description: 'Scans the bugs (issues eroding the ground) of one ground. Looking at ONE bug you already know the number/id of? Call get_bug instead — no need to list. Bugs default to status="active" (wild + chasing): a healed bug is history, and history is not what a scan is for. Ask for "catched" or "all" when you actually want it. Status speaks the product\'s own words — wild (found, nobody on it) → chasing (someone is fixing it) → catched (it landed) — the same words the UI shows. Each bug has a score (0–8 change-risk; 8 = riskiest), and any bug whose solution carries a checklist also carries solutionProgress ("2/5" — ticked/total), so you can see what is left without opening a single body. Targets: a node, an api, or the ground itself. By default returns a summary view — each bug carries id, seq (the number a human says, "#14"), target, title, score, status, createdAt/resolvedAt, plus a 200-char description excerpt. Each node/api bug also carries impactCount — how many other nodes its target reaches by blast radius (affected direction), so you can spot wide-blast bugs at a glance; call get_impact on that bug for the full reached list. Pass view="full" for full descriptions and metadata. Use limit + order to bound a scan (e.g. the 5 newest) instead of pulling every bug.',
408
412
  inputSchema: z.object({
409
413
  slug: z.string().min(1).describe('Ground slug.'),
410
414
  status: z.enum(['active', 'wild', 'chasing', 'catched', 'all', 'open', 'in_progress', 'resolved', 'closed']).optional()
@@ -449,12 +453,19 @@ export function registerTools(server, { api }) {
449
453
  slug: z.string().min(1).describe('Ground slug.'),
450
454
  ref: z.union([z.string().min(1), z.coerce.number().int().positive()])
451
455
  .describe('Bug number (seq, e.g. 14) or internal id (bug-<uuid>).'),
456
+ descriptions: z.enum(['none', 'excerpt', 'full']).optional()
457
+ .describe('How much of description to return. Default "full". Use "none"/"excerpt" when you only came for the solution checklist.'),
458
+ solution: z.enum(['none', 'open', 'full']).optional()
459
+ .describe('How much of solution to return. Default "full". "open" returns only the unchecked items and drops the prose — the cheap answer to "what is left?".'),
452
460
  }),
453
461
  },
454
- async ({ slug, ref }) => {
462
+ async ({ slug, ref, descriptions, solution }) => {
455
463
  try {
464
+ const qs = ['impact=true'];
465
+ if (descriptions) qs.push(`descriptions=${descriptions}`);
466
+ if (solution) qs.push(`solution=${solution}`);
456
467
  const bug = await api.get(
457
- `/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(String(ref))}?impact=true`,
468
+ `/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(String(ref))}?${qs.join('&')}`,
458
469
  );
459
470
  return jsonResult(bug);
460
471
  } catch (e) { return errorResult(e); }
@@ -465,7 +476,7 @@ export function registerTools(server, { api }) {
465
476
  'list_seasons',
466
477
  {
467
478
  title: 'List seasons of a ground',
468
- description: 'Returns the seasons (time epochs) of a ground in chronological order. Seasons are created only by humans — do not attempt to create them via this MCP. See umtri://rules/seasons-human-only. By default returns a summary view (id, label, state, startedAt, grownAt). Pass view="full" to also include metadata.',
479
+ description: 'Returns the seasons (time epochs) of a ground in chronological order. A season is the human\'s declaration that a chapter closed. You may create one with create_season, but only after the user confirms it — see umtri://rules/seasons. By default returns a summary view (id, label, state, startedAt, grownAt). Pass view="full" to also include metadata.',
469
480
  inputSchema: z.object({
470
481
  slug: z.string().min(1).describe('Ground slug.'),
471
482
  view: z.enum(['summary', 'full']).optional().describe('summary (default) drops UI-only metadata; full includes it.'),
@@ -518,7 +529,7 @@ export function registerTools(server, { api }) {
518
529
  'target.kind = "ground" attaches the bug to the project itself (no id).',
519
530
  RISK_RUBRIC,
520
531
  'status defaults to "open".',
521
- 'solution is the fix: at report time it is the plan ("this is probably how we fix it"), and by the time the bug is resolved it should describe what was actually applied. Same field — overwrite it as understanding changes; project_events keeps the diff. Leave it empty rather than guessing.',
532
+ 'solution is the fix, and it belongs to agents — the app gives people no way to edit it, so a human\'s own proposed fix arrives in description instead. At report time solution holds the plan, and by the time the bug is resolved it should describe what was actually applied. Same field — overwrite it as understanding changes; project_events keeps the diff. Write what remains as "- [ ] item" lines so the app can render them as cards and later moves cost one small op (update_bug patch:{solutionItem}). Leave it empty rather than guessing.',
522
533
  'For a node/api bug, the response auto-attaches impact (the affected blast radius from the target): reachedCount, the reached nodes with hop distance and the connection each was reached through, other active bugs sitting in that radius, and a coverage note. Use it to scope what else to check/QA. If the target has no recorded connections the radius is empty — that means nothing is recorded, not that nothing is affected (record edges/apis).',
523
534
  'Requires a write-scope token. See umtri://rules/vocabulary for bug semantics.',
524
535
  ].join(' '),
@@ -538,6 +549,8 @@ export function registerTools(server, { api }) {
538
549
  },
539
550
  async ({ slug, title, target, description, solution, score, status }) => {
540
551
  try {
552
+ // 인자가 필드로 쪼개지지 못한 흔적 — 저장은 되지만 다음 필드가 비어 있다.
553
+ const markupWarnings = toolMarkupCheck({ description, solution });
541
554
  const body = { title, target };
542
555
  if (description !== undefined) body.description = description;
543
556
  if (solution !== undefined) body.solution = solution;
@@ -557,7 +570,7 @@ export function registerTools(server, { api }) {
557
570
  };
558
571
  } catch { /* 영향권 부가 정보 실패는 무시 */ }
559
572
  }
560
- return jsonResult(created);
573
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
561
574
  } catch (e) { return errorResult(e); }
562
575
  },
563
576
  );
@@ -576,10 +589,48 @@ export function registerTools(server, { api }) {
576
589
  },
577
590
  async ({ slug, name, description, visibility }) => {
578
591
  try {
592
+ const markupWarnings = toolMarkupCheck({ description });
579
593
  const body = { slug, name };
580
594
  if (description !== undefined) body.description = description;
581
595
  if (visibility !== undefined) body.visibility = visibility;
582
- return jsonResult(await api.post('/api/projects', body));
596
+ const created = await api.post('/api/projects', body);
597
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
598
+ } catch (e) { return errorResult(e); }
599
+ },
600
+ );
601
+
602
+ // 시즌은 사람의 선언이지만, 옮겨심기처럼 사람이 에이전트에게 맡기고 싶은 자리도 있다.
603
+ // 그래서 도구는 열되 확인 게이트를 둔다 — 첫 호출은 만들지 않고 "무엇이 봉인되는지"를 돌려주고,
604
+ // 사람이 답한 뒤 confirm:true로 다시 부를 때만 만든다. 설명만으로는 강제되지 않아 입력으로 받는다.
605
+ server.registerTool(
606
+ 'create_season',
607
+ {
608
+ title: 'Create a new season',
609
+ description: 'Creates a new season and makes it the "now" season. This is not an additive act: the ground\'s current now-season is sealed as past and every node in it is marked grown, which locks that node\'s parent/season/type/sproutedAt. CONFIRMATION GATE: call once WITHOUT confirm to get back what would be sealed, put that to the user in your own words, and call again with confirm:true only after they say yes. Never pass confirm:true on your own judgment — a season is the human\'s declaration that one chapter closed and the next began, and inventing one makes the ground\'s timeline meaningless. Unsure which season a node belongs to? Attach it to the existing now-season instead of opening a new one. See umtri://rules/seasons.',
610
+ inputSchema: z.object({
611
+ slug: z.string().min(1).describe('Ground slug.'),
612
+ label: z.string().min(1).describe('Season label the human chose — a version, phase, or period (e.g. "1.0.0", "PoC", "Q3 2026").'),
613
+ confirm: z.boolean().optional().describe('Pass true ONLY after the user has agreed to this exact season. Omit it on the first call to receive the confirmation payload instead.'),
614
+ }),
615
+ },
616
+ async ({ slug, label, confirm }) => {
617
+ try {
618
+ if (confirm !== true) {
619
+ const seasons = await api.get(`/api/projects/${encodeURIComponent(slug)}/seasons`);
620
+ const now = Array.isArray(seasons) ? seasons.find((x) => x.state === 'now') : null;
621
+ return jsonResult({
622
+ status: 'confirmation_required',
623
+ created: false,
624
+ wouldCreate: label,
625
+ wouldSeal: now ? { id: now.id, label: now.label } : null,
626
+ effect: now
627
+ ? `Season "${now.label}" would be sealed as past and its nodes locked (parent/season/type/sproutedAt).`
628
+ : 'This ground has no season yet, so nothing would be sealed.',
629
+ next: 'Ask the user to confirm this season, then call create_season again with confirm: true.',
630
+ });
631
+ }
632
+ const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/seasons`, { label });
633
+ return jsonResult(res);
583
634
  } catch (e) { return errorResult(e); }
584
635
  },
585
636
  );
@@ -597,7 +648,7 @@ export function registerTools(server, { api }) {
597
648
  'season ∈ existing season id; omit to use the active "now" season. Past seasons are normally rejected, but allowed while the ground is transplanting (project.transplanting=true) — nodes added then are auto-stamped metadata.transplanted=true for audit. See umtri://rules/transplant.',
598
649
  'The tool validates against protocol policies. Hierarchy violations are rejected. Soft issues (reserved-domain labels, leaf↔vein heuristic, trunk naming) come back as warnings in the response — reconsider before continuing if warnings appear.',
599
650
  'After creating a leaf/vein, consider its connections: if it calls/feeds another node add an api (create_api), if it depends on/is built on another add an edge (create_edge). The response carries a connectionCheck reminder. See umtri://rules/system-structure (Connections).',
600
- 'Creating seasons via MCP is forbidden — see umtri://rules/seasons-human-only.',
651
+ 'Do not open a new season just because none fits — attach to the now-season, or ask the user. create_season needs their confirmation (umtri://rules/seasons).',
601
652
  'When realizing a human-drawn plan brief, any detail nodes you add should carry metadata.plan=true and the realized node needs metadata.implements — see umtri://rules/plan.',
602
653
  ].join(' '),
603
654
  inputSchema: z.object({
@@ -640,7 +691,8 @@ export function registerTools(server, { api }) {
640
691
  if (sproutedAt !== undefined) body.sproutedAt = sproutedAt;
641
692
 
642
693
  const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/nodes`, body);
643
- const out = warnings.length ? { ...created, warnings } : { ...created };
694
+ const allWarnings = warnings.concat(toolMarkupCheck({ description }));
695
+ const out = allWarnings.length ? { ...created, warnings: allWarnings } : { ...created };
644
696
  // 연결 고민 넛지 — leaf/vein은 보통 다른 노드를 호출/의존하므로 매번 확인 유도.
645
697
  if (type === 'leaf' || type === 'vein') {
646
698
  out.connectionCheck = 'Does this node call/feed another (→ create_api) or depend on another (→ create_edge)? Add the connection now if so; skip if genuinely standalone. See umtri://rules/system-structure (Connections).';
@@ -676,6 +728,7 @@ export function registerTools(server, { api }) {
676
728
 
677
729
  const patchResult = validateUpdate({ patch });
678
730
  warnings = warnings.concat(patchResult.warnings);
731
+ warnings = warnings.concat(toolMarkupCheck({ description: patch.description }));
679
732
 
680
733
  if (patch.type !== undefined || patch.parent !== undefined || patch.label !== undefined) {
681
734
  const graph = await api.get(`/api/projects/${encodeURIComponent(slug)}/graph`);
@@ -746,23 +799,6 @@ export function registerTools(server, { api }) {
746
799
  },
747
800
  );
748
801
 
749
- server.registerTool(
750
- 'reopen_transplant',
751
- {
752
- title: 'Re-open transplant on a ground',
753
- description: 'Re-opens the transplant window on a rooted ground (sets project.transplanting=true), relaxing guards so historical/structural reconstruction can resume: add/edit nodes in any season incl. past, and hard-delete mistakes. CRITICAL: only call this when the user has EXPLICITLY asked to switch the ground into transplanting (e.g. "put this ground back in transplant", "옮겨심기로 바꿔줘"). Never decide to re-open transplant on your own judgment — it removes safety guardrails. Rooting it back (settling) is human-only via the UI. See umtri://rules/transplant.',
754
- inputSchema: z.object({
755
- slug: z.string().min(1).describe('Ground slug.'),
756
- }),
757
- },
758
- async ({ slug }) => {
759
- try {
760
- const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/root`, { rooted: false });
761
- return jsonResult(res);
762
- } catch (e) { return errorResult(e); }
763
- },
764
- );
765
-
766
802
  server.registerTool(
767
803
  'commit_plan',
768
804
  {
@@ -866,12 +902,14 @@ export function registerTools(server, { api }) {
866
902
  const { ok, rejectReason } = validateEdge({ sourceId: start, targetId: end });
867
903
  if (!ok) throw new Error(`Rejected by protocol: ${rejectReason}`);
868
904
 
905
+ const markupWarnings = toolMarkupCheck({ label, description });
869
906
  const body = { start, end };
870
907
  if (label !== undefined) body.label = label;
871
908
  if (description !== undefined) body.description = description;
872
909
  if (metadata !== undefined) body.metadata = metadata;
873
910
 
874
- return jsonResult(await api.post(`/api/projects/${encodeURIComponent(slug)}/apis`, body));
911
+ const created = await api.post(`/api/projects/${encodeURIComponent(slug)}/apis`, body);
912
+ return jsonResult(markupWarnings.length ? { ...created, warnings: markupWarnings } : created);
875
913
  } catch (e) { return errorResult(e); }
876
914
  },
877
915
  );
@@ -898,7 +936,9 @@ export function registerTools(server, { api }) {
898
936
  if (patch.start && patch.end && patch.start === patch.end) {
899
937
  throw new Error('Rejected by protocol: API start and end cannot be the same node.');
900
938
  }
901
- return jsonResult(await api.patch(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`, patch));
939
+ const markupWarnings = toolMarkupCheck({ label: patch.label, description: patch.description });
940
+ const updated = await api.patch(`/api/projects/${encodeURIComponent(slug)}/apis/${encodeURIComponent(id)}`, patch);
941
+ return jsonResult(markupWarnings.length ? { ...updated, warnings: markupWarnings } : updated);
902
942
  } catch (e) { return errorResult(e); }
903
943
  },
904
944
  );
@@ -926,10 +966,13 @@ export function registerTools(server, { api }) {
926
966
  title: 'Update a bug',
927
967
  description: [
928
968
  'Partial update of a bug. Most common use: status transition.',
969
+ 'Send only the fields that actually change. A status transition is patch:{status} alone — do not re-send an unchanged solution, description, or metadata. Those fields carry prose, and re-emitting them costs far more time than the update itself and risks a malformed-argument retry.',
929
970
  'Follow the lifecycle one step at a time: open (wild) → in_progress (chasing) → resolved (catched).',
930
971
  'Set status="in_progress" the moment you start the fix, not after it lands — it is the only marker that someone is already on this bug, so a parallel agent can see the work in flight instead of duplicating it. Then set "resolved" once it ships.',
931
972
  'Skipping straight from open to resolved returns a warning (not a rejection) — acceptable when the fix was genuinely instant.',
932
973
  'Other patchable fields: title, description, solution, score (0–8 change-risk), metadata.',
974
+ 'solution is yours to maintain — the app does not let people edit it, so what you write is what the human reads. Their own proposed fix arrives in description instead.',
975
+ 'Keep solution as a checklist: prose for what you learned, "- [ ] item" lines for what remains. To move one item use patch:{solutionItem:{…}} instead of re-sending the whole field — add: "text", or check/uncheck/remove: <1-based item number>. Pass expect:"<start of that item text>" alongside a number so a shifted list fails loudly instead of ticking the wrong line. This is the cheap path: one short op, not a re-emitted body.',
933
976
  'When resolving, rewrite solution to what you actually applied — at report time it held the plan, and leaving a stale plan there is worse than leaving it empty.',
934
977
  'To mark a bug as fixed, prefer status="resolved" over delete — that preserves the history of what eroded the tree.',
935
978
  'When resolving, you may record the shipped release in metadata.resolvedVersion (e.g. "v2.3.1"); metadata is replaced wholesale, so include existing keys you want to keep.',
@@ -942,7 +985,14 @@ export function registerTools(server, { api }) {
942
985
  score: z.coerce.number().int().min(0).max(8).optional().describe('Change risk 0–8 (8 = riskiest). See create_bug for the rubric.'),
943
986
  title: z.string().min(1).max(200).optional(),
944
987
  description: z.string().nullable().optional().describe('What is wrong.'),
945
- solution: z.string().nullable().optional().describe('How it is being / was fixed. Rewrite this to the actually-applied fix when you set status="resolved" — a stale plan left behind is worse than an empty field. null clears it.'),
988
+ solution: z.string().nullable().optional().describe('How it is being / was fixed — prose plus "- [ ] item" lines. Rewrite it to the actually-applied fix when you set status="resolved"; a stale plan left behind is worse than an empty field. null clears it. Replaces the whole field — to move a single item use solutionItem.'),
989
+ solutionItem: z.object({
990
+ add: z.string().min(1).optional().describe('Append a new unchecked item.'),
991
+ check: z.coerce.number().int().positive().optional().describe('Tick item #N (1-based, counting items only).'),
992
+ uncheck: z.coerce.number().int().positive().optional().describe('Untick item #N.'),
993
+ remove: z.coerce.number().int().positive().optional().describe('Delete item #N.'),
994
+ expect: z.string().optional().describe('Guard for check/uncheck/remove: the start of that item\'s text. Mismatch is rejected, so a reordered list cannot silently tick the wrong line.'),
995
+ }).optional().describe('Move ONE checklist item without re-sending solution. Cannot be combined with solution.'),
946
996
  metadata: z.record(z.any()).optional(),
947
997
  }),
948
998
  }),
@@ -960,7 +1010,12 @@ export function registerTools(server, { api }) {
960
1010
  }
961
1011
  const updated = await api.patch(`/api/projects/${encodeURIComponent(slug)}/bugs/${encodeURIComponent(id)}`, patch);
962
1012
  const { warnings } = validateBugUpdate({ currentStatus, patch });
963
- return jsonResult(warnings.length ? { ...updated, warnings } : updated);
1013
+ const allWarnings = warnings.concat(toolMarkupCheck({
1014
+ description: patch?.description,
1015
+ solution: patch?.solution,
1016
+ solutionItem: patch?.solutionItem?.add,
1017
+ }));
1018
+ return jsonResult(allWarnings.length ? { ...updated, warnings: allWarnings } : updated);
964
1019
  } catch (e) { return errorResult(e); }
965
1020
  },
966
1021
  );
@@ -981,4 +1036,253 @@ export function registerTools(server, { api }) {
981
1036
  } catch (e) { return errorResult(e); }
982
1037
  },
983
1038
  );
1039
+
1040
+ // ── Feedback — Umtri 제품 자체에 대한 건의 ────────────────────────────
1041
+ // create_bug과 헷갈리면 남의 프로젝트 기록이 오염된다. 그래서 세 도구 설명 모두
1042
+ // 첫 문장에서 "about Umtri itself, not about your project"를 못박는다.
1043
+ // 사람이 시켰을 때만 부르라는 조건도 여기 둔다 — 에이전트가 자율 발송하면
1044
+ // 자기가 잘 못 쓴 도구를 전부 개선요구로 쏟아내 접수함이 먼저 죽는다.
1045
+ //
1046
+ // 단방향 채널이다: send_feedback만 누구나 쓰고, list/update는 Umtri 운영 계정
1047
+ // 전용(서버가 403). 도구 설명에 그걸 적어 두는 게 비용이 싸다 — 안 적으면
1048
+ // 일반 사용자의 에이전트가 403을 받고 재시도하며 헤맨다.
1049
+
1050
+ server.registerTool(
1051
+ 'send_feedback',
1052
+ {
1053
+ title: 'Send feedback about Umtri itself',
1054
+ description: [
1055
+ 'Files a suggestion or a defect report about **Umtri the product** — this MCP server, its tools, the app, the docs.',
1056
+ 'This is NOT for issues in the user\'s own project: those are bugs on a ground (create_bug).',
1057
+ 'Call this ONLY when the human explicitly asks to leave feedback ("건의해줘", "report this to Umtri"). Do not file feedback on your own initiative — an inbox filled by agents stops being read.',
1058
+ 'kind: "bug" = something in Umtri is broken or wrong; "improvement" = a suggestion / wish (default).',
1059
+ 'source: where it surfaced — the ground slug you were working in, the tool name that felt wrong, the screen. It is free text and is not validated, but it is what makes a report actionable later.',
1060
+ 'One-way: this files the report, and there is no reading it back — do not promise the human a status thread. Requires a write-scope token.',
1061
+ ].join(' '),
1062
+ inputSchema: z.object({
1063
+ kind: z.enum(['bug', 'improvement']).optional()
1064
+ .describe('"bug" for something broken in Umtri, "improvement" for a suggestion. Defaults to "improvement".'),
1065
+ title: z.string().min(1).max(200).describe('One-line summary. Required.'),
1066
+ body: z.string().optional().describe('Details — what you expected, what happened, why it matters (markdown allowed).'),
1067
+ source: z.string().max(200).optional().describe('Where it came up: ground slug, tool name, or screen.'),
1068
+ }),
1069
+ },
1070
+ async ({ kind, title, body, source }) => {
1071
+ try {
1072
+ const markupWarnings = toolMarkupCheck({ title, body });
1073
+ const payload = { title };
1074
+ if (kind !== undefined) payload.kind = kind;
1075
+ if (body !== undefined) payload.body = body;
1076
+ if (source !== undefined) payload.source = source;
1077
+ const filed = await api.post('/api/feedback', payload);
1078
+ return jsonResult(markupWarnings.length ? { ...filed, warnings: markupWarnings } : filed);
1079
+ } catch (e) { return errorResult(e); }
1080
+ },
1081
+ );
1082
+
1083
+ server.registerTool(
1084
+ 'list_feedback',
1085
+ {
1086
+ title: 'List feedback filed about Umtri',
1087
+ description: 'Lists feedback filed about **Umtri the product** (see send_feedback) — not the bugs of any ground. Umtri operators only: every other account gets 403, so do not call this to check on feedback you filed. Newest first. Status: open (untouched) → done (handled) / wontfix (declined, with the reason in note). Defaults to every status; pass status to narrow. Use limit to bound a scan.',
1088
+ inputSchema: z.object({
1089
+ status: z.enum(['open', 'done', 'wontfix']).optional().describe('Filter by status. Omit for all.'),
1090
+ kind: z.enum(['bug', 'improvement']).optional().describe('Filter by kind. Omit for both.'),
1091
+ limit: z.coerce.number().int().positive().optional().describe('Return at most this many.'),
1092
+ order: z.enum(['asc', 'desc']).optional().describe('By creation time. Default "desc" (newest first).'),
1093
+ }),
1094
+ },
1095
+ async ({ status, kind, limit, order }) => {
1096
+ try {
1097
+ const qs = [];
1098
+ if (status) qs.push(`status=${encodeURIComponent(status)}`);
1099
+ if (kind) qs.push(`kind=${encodeURIComponent(kind)}`);
1100
+ if (limit) qs.push(`limit=${limit}`);
1101
+ if (order) qs.push(`order=${encodeURIComponent(order)}`);
1102
+ return jsonResult(await api.get(`/api/feedback${qs.length ? `?${qs.join('&')}` : ''}`));
1103
+ } catch (e) { return errorResult(e); }
1104
+ },
1105
+ );
1106
+
1107
+ server.registerTool(
1108
+ 'update_feedback',
1109
+ {
1110
+ title: 'Update filed feedback',
1111
+ description: 'Updates one feedback record about **Umtri the product** — typically closing it: status="done" once it shipped, or "wontfix" when it was declined. Put what was actually done (or why not) in note; that is the part worth reading a year later. Umtri operators only: every other account gets 403. Send only the fields that change. Requires a write-scope token.',
1112
+ inputSchema: z.object({
1113
+ id: z.coerce.number().int().positive().describe('Feedback number, as returned by send_feedback / list_feedback.'),
1114
+ status: z.enum(['open', 'done', 'wontfix']).optional().describe('open → done (handled) / wontfix (declined).'),
1115
+ note: z.string().optional().describe('What was done about it, or why it was declined.'),
1116
+ kind: z.enum(['bug', 'improvement']).optional(),
1117
+ title: z.string().min(1).max(200).optional(),
1118
+ body: z.string().optional(),
1119
+ source: z.string().max(200).optional(),
1120
+ }),
1121
+ },
1122
+ async ({ id, ...fields }) => {
1123
+ try {
1124
+ const patch = {};
1125
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined) patch[k] = v;
1126
+ if (Object.keys(patch).length === 0) {
1127
+ return errorResult(new Error('Nothing to update — pass at least one field.'));
1128
+ }
1129
+ const markupWarnings = toolMarkupCheck({ title: patch.title, body: patch.body, note: patch.note });
1130
+ const updated = await api.patch(`/api/feedback/${id}`, patch);
1131
+ return jsonResult(markupWarnings.length ? { ...updated, warnings: markupWarnings } : updated);
1132
+ } catch (e) { return errorResult(e); }
1133
+ },
1134
+ );
1135
+
1136
+ // ── Wiki — ground별 지식 저장소 ───────────────────────────────────────
1137
+ // 트리는 구조를 담지 "합의·용어·결정·조사 결과"를 담지 않는다. 그게 갈 곳이 없어
1138
+ // 지금까지 ground description과 대상 없는 버그로 샜다. 문서는 노드에 붙을 수 있되
1139
+ // 독립적으로도 산다 — 붙일 노드가 없다고 못 적게 하면 정작 필요한 초기에 못 쓴다.
1140
+ // 아직 사람용 편집 화면이 없다: 지금은 이 도구들이 유일한 쓰기 경로다.
1141
+
1142
+ server.registerTool(
1143
+ 'list_wiki',
1144
+ {
1145
+ title: 'List wiki pages of a ground',
1146
+ description: 'Lists the ground\'s wiki — the prose the tree cannot hold: conventions, decisions and why they were made, glossary, onboarding. Reference entries describing how things work, not a log of what happened: the change record is list_events, the page\'s own past is in its revisions. Not the structure itself (get_graph) and not defects (list_bugs). Bodies come back as 200-char excerpts by default so a scan stays cheap; pass body="full" only when you mean to read them, or body="none" for a pure index. A page may hang off a node (nodeId) or float free — pass node=<id> for one node\'s pages, or node="none" for the pages not yet attached to anything, which is the list worth reviewing when the tree has grown.',
1147
+ inputSchema: z.object({
1148
+ slug: z.string().min(1).describe('Ground slug.'),
1149
+ node: z.string().optional().describe('Node id to filter by, or "none" for pages attached to nothing yet. Omit for all.'),
1150
+ body: z.enum(['none', 'excerpt', 'full']).optional().describe('How much body text per page. Default "excerpt" (200 chars).'),
1151
+ limit: z.coerce.number().int().positive().optional(),
1152
+ order: z.enum(['asc', 'desc']).optional().describe('By last update. Default "desc" (most recently touched first).'),
1153
+ }),
1154
+ },
1155
+ async ({ slug, node, body, limit, order }) => {
1156
+ try {
1157
+ const qs = [];
1158
+ if (node) qs.push(`node=${encodeURIComponent(node)}`);
1159
+ if (body) qs.push(`body=${body}`);
1160
+ if (limit) qs.push(`limit=${limit}`);
1161
+ if (order) qs.push(`order=${order}`);
1162
+ return jsonResult(await api.get(`/api/projects/${encodeURIComponent(slug)}/wiki${qs.length ? `?${qs.join('&')}` : ''}`));
1163
+ } catch (e) { return errorResult(e); }
1164
+ },
1165
+ );
1166
+
1167
+ server.registerTool(
1168
+ 'get_wiki',
1169
+ {
1170
+ title: 'Read one wiki page',
1171
+ description: 'Returns one wiki page in full. `ref` takes either the page slug (what a human says) or the internal id (wiki-<uuid>) — renaming a page changes its slug but never its id, so ids stay safe to store. Read the page before rewriting it: write_wiki with mode="replace" overwrites the body wholesale. Pass rev=<n> to read an older revision of the page instead of the current one (list them with list_wiki_revisions); to put an old revision back, do not paste its body into a write — use write_wiki mode="restore", which cannot mangle the prose in transit.',
1172
+ inputSchema: z.object({
1173
+ slug: z.string().min(1).describe('Ground slug.'),
1174
+ ref: z.string().min(1).describe('Page slug (e.g. "deploy-notes") or id (wiki-<uuid>).'),
1175
+ rev: z.coerce.number().int().positive().optional().describe('Revision number to read. Omit for the current page.'),
1176
+ }),
1177
+ },
1178
+ async ({ slug, ref, rev }) => {
1179
+ try {
1180
+ const base = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`;
1181
+ return jsonResult(await api.get(rev ? `${base}/revisions/${rev}` : base));
1182
+ } catch (e) { return errorResult(e); }
1183
+ },
1184
+ );
1185
+
1186
+ server.registerTool(
1187
+ 'list_wiki_revisions',
1188
+ {
1189
+ title: 'List the revision history of a wiki page',
1190
+ description: [
1191
+ 'Returns what this page said before, newest first — rev number, who wrote it, when, and the body length. Bodies are not included (a history of twenty full documents is expensive to skim); read one with get_wiki rev=<n>.',
1192
+ 'Use it to answer "what did this say last week", to see whether a write of yours dropped something, and to find the rev to hand back to write_wiki mode="restore".',
1193
+ 'Consecutive writes by the same actor within 30 minutes fold into one revision rather than piling up, so the list stays readable — a working session is one entry, not eight. The practical guarantee: whatever an agent does to a page in one session, the body from before that session is still a separate revision.',
1194
+ 'Revisions start at the moment the ground gained this feature; edits older than that were never recorded and cannot be recovered.',
1195
+ ].join(' '),
1196
+ inputSchema: z.object({
1197
+ slug: z.string().min(1).describe('Ground slug.'),
1198
+ ref: z.string().min(1).describe('Page slug or id.'),
1199
+ limit: z.coerce.number().int().positive().optional(),
1200
+ order: z.enum(['asc', 'desc']).optional().describe('By revision number. Default "desc" (newest first).'),
1201
+ }),
1202
+ },
1203
+ async ({ slug, ref, limit, order }) => {
1204
+ try {
1205
+ const qs = [];
1206
+ if (limit) qs.push(`limit=${limit}`);
1207
+ if (order) qs.push(`order=${order}`);
1208
+ const path = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}/revisions`;
1209
+ return jsonResult(await api.get(`${path}${qs.length ? `?${qs.join('&')}` : ''}`));
1210
+ } catch (e) { return errorResult(e); }
1211
+ },
1212
+ );
1213
+
1214
+ server.registerTool(
1215
+ 'write_wiki',
1216
+ {
1217
+ title: 'Create or update a wiki page',
1218
+ description: [
1219
+ 'Writes one wiki page. Creates it if the page slug is new, updates it otherwise — you do not need to check first.',
1220
+ 'Write what the tree cannot hold and what a maintainer would otherwise have to reconstruct: why a decision went the way it did, the vocabulary this project uses, what an investigation concluded. Do not restate the structure — that is what nodes are for.',
1221
+ '**A page is a description, not a log.** Write it in the present tense, as a reference entry for the thing: how it works now, and the reasons that still constrain it. Do not append what you just did, do not add dated entries or a change-history section, do not keep progress checklists here — those go to bugs, plan nodes, list_events, and record_commit, and the page\'s own past is already kept in its revisions. When something changes, rewrite the sentence that is now wrong rather than adding a sentence saying it changed.',
1222
+ 'mode="replace" (default) overwrites the whole body — read the page first, or you will drop what was there; this is the normal way to update a page. mode="append" is the narrow case: adding a section the page genuinely lacked, never tacking on this session\'s summary. mode="restore" puts an earlier revision back: pass rev=<n> and nothing else, and the page returns to what it said then.',
1223
+ 'node attaches the page to a node; omit it and the page floats free, which is fine and common early on. Attach it later once the tree has a place for it. Deleting that node detaches the page rather than deleting it.',
1224
+ 'Every write is kept as a revision, so a replace that drops prose can be undone (list_wiki_revisions, then mode="restore"). Consecutive writes by the same actor within 30 minutes fold into one revision instead of piling up, which means the body from before your session stays recoverable as its own revision no matter how many times you rewrite the page now.',
1225
+ 'Pass newRevision=true when the page moves to a new stage rather than being polished further — most importantly when you first write back what you actually built over a page that until now held the plan. Folding is judged by clock and author, which cannot tell "still drafting" from "the plan became the thing"; only you know that, and without the flag the plan is quietly overwritten by its own outcome.',
1226
+ 'rename moves the page to a new slug, keeping its id and its whole revision history — do not create a new page and delete the old one, that throws the history away.',
1227
+ 'The response tells you what the write did: rev is the revision it landed in, coalesced says whether it folded into your previous one, and previousBodyChars is how long the body was before you wrote. If previousBodyChars is much larger than what you just wrote, you replaced someone\'s work — check list_wiki_revisions before moving on.',
1228
+ 'baseRev is optional and is how you avoid clobbering a concurrent edit: pass the rev you read, and the write is refused if the page moved on since. Use it when you read, think, then write.',
1229
+ 'The change also lands in the ground\'s history (list_events, entityType="wiki"), where the body is recorded as a length delta rather than a copy of the prose — the prose itself lives in the revisions.',
1230
+ 'Requires a write-scope token.',
1231
+ ].join(' '),
1232
+ inputSchema: z.object({
1233
+ slug: z.string().min(1).describe('Ground slug.'),
1234
+ page: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).describe('Page slug — lowercase letters, digits, hyphens. The name humans use, e.g. "deploy-notes".'),
1235
+ title: z.string().min(1).max(200).optional().describe('Display title. Required when creating a page.'),
1236
+ body: z.string().optional().describe('Markdown body. With mode="append" this is the text to add. Ignored by mode="restore".'),
1237
+ node: z.string().nullable().optional().describe('Node id to attach to. null detaches. Omit to leave the current attachment untouched.'),
1238
+ mode: z.enum(['replace', 'append', 'restore']).optional().describe('"replace" (default) overwrites the body — the normal way to update a page; "append" adds a missing section to the end (not a session summary); "restore" brings back the revision named by rev.'),
1239
+ rev: z.coerce.number().int().positive().optional().describe('With mode="restore": the revision number to bring back (from list_wiki_revisions).'),
1240
+ baseRev: z.coerce.number().int().positive().optional().describe('The revision you based this write on. If the page has moved past it, the write is refused instead of overwriting.'),
1241
+ // 문자열도 받는다. 어떤 MCP 클라이언트는 boolean 인자를 "true"로 직렬화해서
1242
+ // 보내는데, 그 경우 z.boolean()은 통째로 거부한다 — 계획이 덮이는 걸 막으라고
1243
+ // 만든 플래그가 정작 클라이언트마다 안 먹으면 없느니만 못하다.
1244
+ // z.coerce.boolean()은 쓰면 안 된다: "false"가 true가 된다.
1245
+ newRevision: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
1246
+ .optional().describe('Force a new revision instead of folding into the last one. Use when this write ends one stage of the page and begins another — above all when replacing a plan with what was actually built.'),
1247
+ rename: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).optional().describe('New page slug. Keeps the id and the revision history; refused if that name is taken.'),
1248
+ }),
1249
+ },
1250
+ async ({ slug, page, title, body, node, mode, rev, baseRev, newRevision, rename }) => {
1251
+ try {
1252
+ const payload = { slug: page };
1253
+ if (title !== undefined) payload.title = title;
1254
+ if (body !== undefined) payload.body = body;
1255
+ if (node !== undefined) payload.node = node;
1256
+ if (mode !== undefined) payload.mode = mode;
1257
+ if (rev !== undefined) payload.rev = rev;
1258
+ if (baseRev !== undefined) payload.baseRev = baseRev;
1259
+ if (newRevision !== undefined) payload.newRevision = newRevision;
1260
+ if (rename !== undefined) payload.rename = rename;
1261
+ const written = await api.put(`/api/projects/${encodeURIComponent(slug)}/wiki`, payload);
1262
+ // 로그 형태 경고는 쓰기를 막지 않는다 — 이미 저장된 뒤에 붙인다. 판정이 휴리스틱이라
1263
+ // 거부하면 정당한 문서까지 막히고, 어차피 리비전이 있어 되돌릴 수 있다.
1264
+ // 검사 대상은 저장된 본문이다: append면 이어붙인 결과 전체를 봐야 한다.
1265
+ const warnings = wikiLogShapeCheck(written?.body)
1266
+ .concat(toolMarkupCheck({ title, body: written?.body }));
1267
+ return jsonResult(warnings.length ? { ...written, warnings } : written);
1268
+ } catch (e) { return errorResult(e); }
1269
+ },
1270
+ );
1271
+
1272
+ server.registerTool(
1273
+ 'delete_wiki',
1274
+ {
1275
+ title: 'Delete a wiki page',
1276
+ description: 'Permanently removes a wiki page **and its whole revision history** — unlike an overwrite, this cannot be undone, and only the history entry (list_events) remains to say it happened. Prefer rewriting a page that went stale over deleting it: a page that records a decision you have since reversed is worth keeping with the reversal written in. Deleting is for a page that should never have existed, not for one that is out of date.',
1277
+ inputSchema: z.object({
1278
+ slug: z.string().min(1).describe('Ground slug.'),
1279
+ ref: z.string().min(1).describe('Page slug or id.'),
1280
+ }),
1281
+ },
1282
+ async ({ slug, ref }) => {
1283
+ try {
1284
+ return jsonResult(await api.delete(`/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`));
1285
+ } catch (e) { return errorResult(e); }
1286
+ },
1287
+ );
984
1288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umtri-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "MCP server for Umtri — give your AI coding agent a persistent map of your project's structure, dependencies, and bugs.",
5
5
  "mcpName": "io.umtri/umtri",
6
6
  "keywords": [
@@ -1,33 +0,0 @@
1
- # Rule: Seasons Are Created by Humans Only
2
-
3
- **AI tools and automation MUST NOT create new seasons.**
4
-
5
- ## What a season is
6
-
7
- A `season` is a deliberate growth epoch declared by a human — for example
8
- "Q3 2026", "v2 redesign", "post-launch hardening". It is the unit by which
9
- project owners *consciously break time* in their ground.
10
-
11
- ## Why this rule exists
12
-
13
- Seasons carry intent. They mark moments where a person decides "this chapter
14
- is done, the next one begins." If an AI agent silently opens a new season
15
- when it can't decide which existing one to use, the metaphor collapses and
16
- the project's timeline becomes meaningless noise.
17
-
18
- ## What this means in practice
19
-
20
- - The MCP tool `create_season` is intentionally **not exposed**.
21
- - When creating a new node and unsure which season it belongs to:
22
- - Default to the most recent existing `season` whose state is `now`.
23
- - If no `now` season exists, ask the user which season to attach to.
24
- - Never call `POST /api/projects/:slug/seasons` from automation.
25
- - A user explicitly saying "create a new season called X" is the only valid
26
- trigger. Even then, prefer to confirm before acting.
27
-
28
- ## What is allowed
29
-
30
- - Reading seasons (`list_seasons`).
31
- - Attaching new nodes to existing seasons via `season` field.
32
- - Suggesting to the user that a new season *might* be useful — but waiting
33
- for them to create it.