umtri-mcp 1.5.2 → 1.7.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 +3 -1
- package/core/api-client.js +7 -2
- package/core/policy.js +14 -4
- package/core/resources/system-structure.md +10 -2
- package/core/resources/trunk-kinds.md +49 -5
- package/core/resources/vocabulary-detailed.md +7 -5
- package/core/resources/vocabulary.md +7 -1
- package/core/resources/wiki.md +15 -2
- package/core/resources.js +1 -1
- package/core/tools.js +103 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# umtri-mcp
|
|
2
2
|
|
|
3
|
+
[](https://mcprush.com/to/umtri-mcp?ref=badge)
|
|
4
|
+
|
|
3
5
|
MCP server for [Umtri](https://umtri.io) — give your AI coding agent a persistent map of your
|
|
4
6
|
project's structure, dependencies, and bugs.
|
|
5
7
|
|
|
@@ -62,7 +64,7 @@ appear and then error, re-check the token and its scope — `read` tokens cannot
|
|
|
62
64
|
|
|
63
65
|
**Plan loop** — `commit_plan`, `record_commit`
|
|
64
66
|
|
|
65
|
-
**Wiki** — `list_wiki`, `get_wiki`, `list_wiki_revisions`, `write_wiki`, `delete_wiki`. The ground's
|
|
67
|
+
**Wiki** — `list_wiki`, `get_wiki`, `list_wiki_revisions`, `write_wiki`, `edit_wiki`, `delete_wiki`. The ground's
|
|
66
68
|
encyclopedia: pages form a table of contents (`parent`, `position`) read from an overview down,
|
|
67
69
|
each has a kind, and every write comes back with warnings when a page strays from the skeleton.
|
|
68
70
|
|
package/core/api-client.js
CHANGED
|
@@ -12,9 +12,14 @@ export function createApiClient({ baseUrl, getToken }) {
|
|
|
12
12
|
return `Bearer ${token}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// 서버가 "이 사용자가 MCP로 연결해 본 적 있음"을 기록하는 표시 — stdio·HTTP 공용.
|
|
16
|
+
async function headers() {
|
|
17
|
+
return { Authorization: await authHeader(), 'X-Umtri-Client': 'mcp' };
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
async function get(path) {
|
|
16
21
|
const res = await fetch(`${base}${path}`, {
|
|
17
|
-
headers:
|
|
22
|
+
headers: await headers(),
|
|
18
23
|
});
|
|
19
24
|
if (!res.ok) {
|
|
20
25
|
const body = await res.text().catch(() => '');
|
|
@@ -26,7 +31,7 @@ export function createApiClient({ baseUrl, getToken }) {
|
|
|
26
31
|
async function mutate(method, path, body) {
|
|
27
32
|
const init = {
|
|
28
33
|
method,
|
|
29
|
-
headers:
|
|
34
|
+
headers: await headers(),
|
|
30
35
|
};
|
|
31
36
|
if (body != null) {
|
|
32
37
|
init.headers['Content-Type'] = 'application/json';
|
package/core/policy.js
CHANGED
|
@@ -27,7 +27,7 @@ const TRUNK_PATTERN = /^[a-z][a-z0-9-]*(-app|-server|-service|-job|-cdn)$|^[a-z]
|
|
|
27
27
|
// Trunk kinds — umtri://rules/trunk-kinds. Recorded in trunk metadata.kind.
|
|
28
28
|
// A trunk that fits none of these keeps kind unset; the agent asks the human whether to
|
|
29
29
|
// suggest a new kind via send_feedback (never files it on its own).
|
|
30
|
-
export const TRUNK_KINDS = ['front', 'server', 'database', 'library', 'device'];
|
|
30
|
+
export const TRUNK_KINDS = ['front', 'server', 'database', 'library', 'device', 'tooling'];
|
|
31
31
|
|
|
32
32
|
// Suffixes that repeat what the trunk kind already says ("예약 API", "Reservation tables").
|
|
33
33
|
// Matched at the end of a limb label directly under a trunk.
|
|
@@ -132,11 +132,11 @@ export function trunkNamingCheck(type, label) {
|
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// trunk에 kind가 없거나
|
|
135
|
+
// trunk에 kind가 없거나 유형 목록 밖이면 알린다. 거부하지 않는다 — 기존 나무가 대부분 kind 없이 있다.
|
|
136
136
|
export function trunkKindCheck(type, metadata) {
|
|
137
137
|
if (type !== 'trunk') return null;
|
|
138
138
|
const kind = metadata?.kind;
|
|
139
|
-
const fallback = 'If none of
|
|
139
|
+
const fallback = 'If none of them fits, leave kind unset, tell the human which trunk did not fit and why, and offer to suggest a new kind with send_feedback (kind "improvement") — do not file it on your own. See umtri://rules/trunk-kinds.';
|
|
140
140
|
if (kind == null || kind === '') {
|
|
141
141
|
return {
|
|
142
142
|
rule: 'trunk-kind-missing',
|
|
@@ -179,6 +179,7 @@ export function limbUnderTrunkChecks({ type, label, parentType, parentKind, meta
|
|
|
179
179
|
database: 'domains, named like the server',
|
|
180
180
|
library: 'capabilities or concepts',
|
|
181
181
|
device: 'physical parts or screen regions',
|
|
182
|
+
tooling: 'what the tools do (build & deploy, measurement & simulation, docs & conventions, dev environment)',
|
|
182
183
|
}[parentKind] ?? "the trunk kind's axis (umtri://rules/trunk-kinds)";
|
|
183
184
|
out.push({
|
|
184
185
|
rule: 'limb-layer-axis',
|
|
@@ -238,7 +239,7 @@ export function dormancyHintCheck(type) {
|
|
|
238
239
|
return {
|
|
239
240
|
rule: 'structure-without-children',
|
|
240
241
|
severity: 'info',
|
|
241
|
-
message: `${type} is a structure node — it will render as dormant (dim) until it has a leaf or vein descendant. If this represents a single component or object (one React component, one file, one service), type='leaf' directly under
|
|
242
|
+
message: `${type} is a structure node — it will render as dormant (dim) until it has a leaf or vein descendant. If this represents a single component or object (one React component, one file, one service), type='leaf' directly under a limb is allowed (limb→leaf OK; not trunk→leaf — see umtri://rules/vocabulary).`,
|
|
242
243
|
};
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -460,6 +461,15 @@ export function hierarchyCheck(parentType, childType) {
|
|
|
460
461
|
message: 'A vein is meant to live under a leaf (object), not directly under structure. Consider adding a leaf parent first.',
|
|
461
462
|
};
|
|
462
463
|
}
|
|
464
|
+
// trunk 바로 아래는 limb만이다. 객체 하나짜리 도메인도 limb(잎 하나)이거나 상위 개념 limb의 twig·leaf다 —
|
|
465
|
+
// 직속 leaf는 그래프에서 limb처럼 길게 뻗어 trunk의 도메인 목록을 흐린다(umtri://rules/trunk-kinds).
|
|
466
|
+
if (parentType === 'trunk' && childRole === 'object') {
|
|
467
|
+
return {
|
|
468
|
+
rule: 'trunk-direct-leaf',
|
|
469
|
+
severity: 'warn',
|
|
470
|
+
message: 'A trunk holds limbs, not leaves. Put this leaf under the limb of its domain — a one-object domain is still a limb — or under the limb of the parent concept it belongs to. See umtri://rules/trunk-kinds ("What earns a limb").',
|
|
471
|
+
};
|
|
472
|
+
}
|
|
463
473
|
return null;
|
|
464
474
|
}
|
|
465
475
|
|
|
@@ -64,6 +64,13 @@ Keep out:
|
|
|
64
64
|
- **Tooling config with no runtime behaviour** — linter/formatter configs,
|
|
65
65
|
editor settings, `.gitignore`.
|
|
66
66
|
|
|
67
|
+
**Tooling that runs is in.** A build or deploy script, a simulator, a docs
|
|
68
|
+
generator, a test that checks the repo's conventions — the team runs these, and
|
|
69
|
+
deleting them changes how the team's work behaves even though the product does
|
|
70
|
+
not. They go into a `tooling` trunk (`umtri://rules/trunk-kinds`), not under the
|
|
71
|
+
app they serve and not out of the tree. The line is *runs* vs *inert*: a config
|
|
72
|
+
file with no behaviour of its own stays out, the script that reads it is in.
|
|
73
|
+
|
|
67
74
|
Point at documents instead of turning them into nodes: put the reference in the
|
|
68
75
|
`description` of the node it explains (e.g. "propagation rules:
|
|
69
76
|
`PRD-impact-analysis.md`"). The document stays findable exactly where someone
|
|
@@ -81,7 +88,8 @@ users* vs *read by us*.
|
|
|
81
88
|
- `limb` = a category inside a trunk (e.g. `graph`, `table`, `dashboard`).
|
|
82
89
|
How a trunk splits into limbs depends on its kind (`metadata.kind`): features
|
|
83
90
|
for a front, domains for a server and its database (same names, no suffix),
|
|
84
|
-
capabilities for a library, parts for a device
|
|
91
|
+
capabilities for a library, parts for a device, what the tools do for tooling —
|
|
92
|
+
one common limb per trunk.
|
|
85
93
|
See `umtri://rules/trunk-kinds`.
|
|
86
94
|
- Do **not** create one limb per time period. Time is expressed through
|
|
87
95
|
`season` and `created_at`, not by carving the tree into temporal slices.
|
|
@@ -110,7 +118,7 @@ questions about a ground.
|
|
|
110
118
|
| `role_change_at` | type-changed nodes | `string` — ISO date |
|
|
111
119
|
| `role_change_reason` | type-changed nodes | `string` — short prose explaining the shift |
|
|
112
120
|
| `placeholder` | intentionally-empty structure | `true` — opts out of dormant cleanup suggestions |
|
|
113
|
-
| `kind` | trunks | `front` \| `server` \| `database` \| `library` \| `device` |
|
|
121
|
+
| `kind` | trunks | `front` \| `server` \| `database` \| `library` \| `device` \| `tooling` |
|
|
114
122
|
| `common` | the one shared limb of a trunk | `true` — see `umtri://rules/trunk-kinds` |
|
|
115
123
|
|
|
116
124
|
## One file, multiple nodes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Trunk Kinds — How Each Kind of Trunk Branches
|
|
2
2
|
|
|
3
|
-
Every trunk is one of
|
|
3
|
+
Every trunk is one of six kinds, and each kind has its own rule for what its
|
|
4
4
|
limbs are. Recording the kind lets the same domain line up across trunks —
|
|
5
5
|
`Reservations` as a screen area, as endpoints, and as tables — instead of each
|
|
6
6
|
trunk being carved along a different axis.
|
|
@@ -17,11 +17,17 @@ label: the kind is what the MCP reads to check the limbs under it.
|
|
|
17
17
|
| `database` | A data store — relational DB, document DB, cache | Domain, **with the same names as the server** | Tables shared by many domains (sessions, audit log) | Tables, labelled by their logical name, with `metadata.table` (the real name) and `metadata.columns` |
|
|
18
18
|
| `library` | Shared code or a package that other trunks import | Capability or concept (Money, Signal pipeline) | Shared types and constants | Modules |
|
|
19
19
|
| `device` | Physical hardware, an embedded screen, an enclosure | Physical part or screen region | Device-wide parts (power, communication) | Parts, screen elements |
|
|
20
|
+
| `tooling` | What the team runs but never ships — build and deploy scripts, store upload automation, simulators and measurement tools, docs generators, convention-checking tests, agent skills | What the tools do (build & deploy / measurement & simulation / docs & conventions / dev environment) | Loaders and helpers the scripts share (a `registerBabel`, a shared CLI harness) | Scripts, skills, generators |
|
|
20
21
|
|
|
21
22
|
Static sites and admin panels are `front`. A monorepo package that apps import
|
|
22
|
-
is `library`.
|
|
23
|
+
is `library`. Scripts the team runs against the product — a balance simulator,
|
|
24
|
+
a docs generator, a Play upload job, a test that checks the repo's conventions —
|
|
25
|
+
are `tooling`: the mirror image of a library (a library is imported by other
|
|
26
|
+
trunks; tooling imports or executes them). Do not fold them into the app trunk
|
|
27
|
+
they serve — that blurs what the app ships. Inert config and documents are
|
|
28
|
+
still not nodes (see `umtri://rules/system-structure`).
|
|
23
29
|
|
|
24
|
-
### When none of
|
|
30
|
+
### When none of them fits
|
|
25
31
|
|
|
26
32
|
Do not force it. Leave `metadata.kind` unset, tell the human which trunk did
|
|
27
33
|
not fit and why, and **offer** to report it with `send_feedback` (kind
|
|
@@ -61,6 +67,42 @@ resource too: `Members`, not `Member management`.
|
|
|
61
67
|
When adding a limb, look at the other trunks first. If the domain already
|
|
62
68
|
exists there, reuse its exact label.
|
|
63
69
|
|
|
70
|
+
## What earns a limb
|
|
71
|
+
|
|
72
|
+
A limb is a division the trunk's *user* would make — an area they name and
|
|
73
|
+
walk into on its own. Matching names across trunks is not a reason to give
|
|
74
|
+
every trunk a limb for every domain. Two rules keep limbs honest:
|
|
75
|
+
|
|
76
|
+
1. **A trunk's direct children are limbs — never leaves.** A one-object
|
|
77
|
+
domain (`Sessions` as one table, `Tokens` as one endpoint group) is still a
|
|
78
|
+
limb holding that one leaf, unless rule 2 gives it a parent concept to live
|
|
79
|
+
under. Do not put the leaf directly under the trunk: there it reads as an
|
|
80
|
+
unnamed domain, and the graph draws it as long as a limb anyway.
|
|
81
|
+
2. **Small domains that share a parent concept are one limb.** When several
|
|
82
|
+
domains in a trunk are small (two objects or fewer, or the parts of a
|
|
83
|
+
single screen) and the trunk's user would call them one thing, they become
|
|
84
|
+
**one limb named for that thing**, and each absorbed domain stays as a
|
|
85
|
+
**twig that keeps its name** (a domain of one object becomes a leaf of the
|
|
86
|
+
limb). The alignment across trunks is by label — a domain that is a limb
|
|
87
|
+
in the front may be a twig of a bigger limb in the server, and the names
|
|
88
|
+
still line up. Name the parent concept the way the user does, never as a
|
|
89
|
+
layer or a grab-bag:
|
|
90
|
+
|
|
91
|
+
| kind | the parent concept is | example |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `server`, `database` | the resource the small domains hang off | `Seasons`, `Bugs`, `Events` routes → twigs/leaves of `Grounds`; `Auth`, `Tokens`, `Orgs` → `Accounts`; admin and public endpoints → `Operations` |
|
|
94
|
+
| `front` | the screen they appear on together | tree layers, season tabs, the time slider → twigs of `Tree`; invite page → twig of `Accounts` |
|
|
95
|
+
|
|
96
|
+
If no honest parent concept exists, leave the small domains as limbs of
|
|
97
|
+
their own, one leaf each — the graph folds a one-leaf limb into a narrow
|
|
98
|
+
slot.
|
|
99
|
+
|
|
100
|
+
Real grounds that skipped rule 2 ended up with half their limbs holding a
|
|
101
|
+
single leaf and a dozen two-leaf limbs for one screen's parts — each one a
|
|
102
|
+
level that says nothing. `get_graph` reports that as the `thin-limb` IA hint
|
|
103
|
+
(per trunk, when half or more of its limbs are thin): look for the parent
|
|
104
|
+
concept those limbs share before adding another.
|
|
105
|
+
|
|
64
106
|
## Connections between kinds
|
|
65
107
|
|
|
66
108
|
| From → to | Record as |
|
|
@@ -69,6 +111,7 @@ exists there, reuse its exact label.
|
|
|
69
111
|
| server endpoint → database table | edge `data_flow` (writes) or `dependency` (reads) |
|
|
70
112
|
| table → table (foreign key) | edge `dependency` (referencing → referenced) |
|
|
71
113
|
| any trunk → library module | edge `dependency` |
|
|
114
|
+
| tooling script → the trunk it runs or checks | edge `dependency` (tooling → target) |
|
|
72
115
|
|
|
73
116
|
## Warnings
|
|
74
117
|
|
|
@@ -77,8 +120,9 @@ exists there, reuse its exact label.
|
|
|
77
120
|
|
|
78
121
|
| Rule | Meaning |
|
|
79
122
|
| --- | --- |
|
|
80
|
-
| `trunk-kind-missing` | A trunk has no `metadata.kind`. Pick one of the
|
|
81
|
-
| `trunk-kind-unknown` | `metadata.kind` is not one of the
|
|
123
|
+
| `trunk-kind-missing` | A trunk has no `metadata.kind`. Pick one of the six, or follow "When none of them fits". |
|
|
124
|
+
| `trunk-kind-unknown` | `metadata.kind` is not one of the six. Same advice. |
|
|
125
|
+
| `trunk-direct-leaf` | A leaf is created or moved directly under a trunk. Give it a limb (rule 1) or put it under the limb of its parent concept (rule 2). |
|
|
82
126
|
| `limb-domain-suffix` | A limb directly under a trunk ends in a kind suffix (`API`, `응답`, `테이블`, `관리`, `Table(s)`, `Admin`, `Management`). Drop it and reuse the domain name. |
|
|
83
127
|
| `limb-layer-axis` | A limb is named after a technical layer. Split by the trunk kind's axis instead. |
|
|
84
128
|
| `common-limb-duplicate` | The trunk already has a limb with `metadata.common: true`. |
|
|
@@ -16,9 +16,11 @@ The schema accepts any combination. The MCP warns when violated but lets
|
|
|
16
16
|
the data through (legacy migration, experimental use). For new authoring,
|
|
17
17
|
follow the table strictly.
|
|
18
18
|
|
|
19
|
-
**Skip-level traversal is allowed
|
|
20
|
-
`
|
|
21
|
-
|
|
19
|
+
**Skip-level traversal is allowed below a limb**: `limb → leaf` and
|
|
20
|
+
`trunk → twig` are valid. The full descent `trunk → limb → twig → leaf → vein`
|
|
21
|
+
is canonical, not mandatory — but a trunk's direct children are limbs, never
|
|
22
|
+
leaves (`trunk → leaf` warns with `trunk-direct-leaf`; see
|
|
23
|
+
`umtri://rules/trunk-kinds`, "What earns a limb").
|
|
22
24
|
|
|
23
25
|
## Trunk = system-unit, not domain
|
|
24
26
|
|
|
@@ -32,8 +34,8 @@ Domains or feature areas (auth, payments, reservations, dashboard, …) are
|
|
|
32
34
|
that spans the user-app and admin trunks" — fine. "Authentication is a
|
|
33
35
|
trunk" — no.
|
|
34
36
|
|
|
35
|
-
Record which of the
|
|
36
|
-
`server`, `database`, `library`, `device`). The kind decides how its limbs
|
|
37
|
+
Record which of the six kinds the trunk is in `metadata.kind` (`front`,
|
|
38
|
+
`server`, `database`, `library`, `device`, `tooling`). The kind decides how its limbs
|
|
37
39
|
split — see `umtri://rules/trunk-kinds`.
|
|
38
40
|
|
|
39
41
|
Naming convention (recommended, not enforced):
|
|
@@ -172,12 +172,18 @@ If you see one of these in the response, read it before proceeding:
|
|
|
172
172
|
| `structure-without-children` | New limb/twig will render dormant until a leaf/vein appears. |
|
|
173
173
|
| `twig-promotion-candidate` | Twig description suggests it should be a sibling limb. |
|
|
174
174
|
| `redundant-child` | A twig and its only leaf duplicate the same concept. |
|
|
175
|
-
| `trunk-kind-missing` / `trunk-kind-unknown` | Trunk has no / an unknown `metadata.kind` (front, server, database, library, device). |
|
|
175
|
+
| `trunk-kind-missing` / `trunk-kind-unknown` | Trunk has no / an unknown `metadata.kind` (front, server, database, library, device, tooling). |
|
|
176
176
|
| `limb-domain-suffix` | Limb under a trunk ends in `API`/`응답`/`테이블`/`관리`… — name the domain alone. |
|
|
177
177
|
| `limb-layer-axis` | Limb is a technical layer (State, Utils, Routes, Middleware…). |
|
|
178
178
|
| `common-limb-duplicate` | The trunk already has its one common limb (`metadata.common`). |
|
|
179
179
|
| `common-content-outside-common` | Front limb holds common content (design system, shell…) outside the common limb, or a `공통` limb isn't marked. |
|
|
180
180
|
|
|
181
|
+
`get_graph` also attaches `iaHints` on the whole tree: `missing-implements-coverage`,
|
|
182
|
+
`isolated-leaf-vein`, `sibling-cluster` (twig candidate), and `thin-limb` — a trunk
|
|
183
|
+
where half or more of the limbs hold a single leaf or nothing: small domains that
|
|
184
|
+
share a parent concept belong in one limb (see `umtri://rules/trunk-kinds`,
|
|
185
|
+
"What earns a limb"). A leaf directly under a trunk warns with `trunk-direct-leaf`.
|
|
186
|
+
|
|
181
187
|
## Further reading (read only when relevant)
|
|
182
188
|
|
|
183
189
|
- `umtri://rules/trunk-kinds` — the five trunk kinds and how each splits into limbs
|
package/core/resources/wiki.md
CHANGED
|
@@ -160,7 +160,7 @@ it." That is not a diary entry, it is a hazard.
|
|
|
160
160
|
|
|
161
161
|
## The write tells you what is off
|
|
162
162
|
|
|
163
|
-
`write_wiki`
|
|
163
|
+
`write_wiki` and `edit_wiki` answer with `warnings[]` when the saved page strays from the
|
|
164
164
|
skeleton: no kind, no parent (outside the table of contents), a title that is
|
|
165
165
|
not a plain noun, no lead, a status banner, a dated heading, a checklist, a
|
|
166
166
|
change-history section, a top-level section other than Spec/Rules, a missing
|
|
@@ -217,11 +217,24 @@ separately. Do not split by document type — `mcp` and `mcp-architecture` are
|
|
|
217
217
|
one page at two points in its life. Do not create a page per file, per sprint,
|
|
218
218
|
or per meeting.
|
|
219
219
|
|
|
220
|
+
## Choosing how to read
|
|
221
|
+
|
|
222
|
+
Pages run long. `get_wiki` returns the whole body by default, and you rarely
|
|
223
|
+
need it.
|
|
224
|
+
|
|
225
|
+
| | |
|
|
226
|
+
| --- | --- |
|
|
227
|
+
| A page you have not seen | `view="outline"` — the lead and the heading tree with sizes |
|
|
228
|
+
| One topic | `section="<heading or path>"` — that section with the footnotes it cites |
|
|
229
|
+
| The exact lines to change | `find="<text>"` — matching lines with context, line-numbered; a block is an `edit_wiki` `oldString` |
|
|
230
|
+
| About to `replace` the body | read it **in full** — an outline is not the body |
|
|
231
|
+
|
|
220
232
|
## Choosing how to write
|
|
221
233
|
|
|
222
234
|
| | |
|
|
223
235
|
| --- | --- |
|
|
224
|
-
| The thing changed | `mode="replace"` — **read the page first** (`get_wiki
|
|
236
|
+
| The thing changed | `mode="replace"` — **read the page first** (`get_wiki`, in full) |
|
|
237
|
+
| One passage is wrong on a long page | `edit_wiki` — the exact text and its replacement; it must match once, so copy from `get_wiki` |
|
|
225
238
|
| The page lacks a whole topic you can add as its own `###` | `mode="replace"` with it folded in; `append` only if it truly stands alone at the end |
|
|
226
239
|
| Crossing into a new stage | add `newRevision: true` |
|
|
227
240
|
| A write went wrong | `list_wiki_revisions`, then `mode="restore"` with the rev |
|
package/core/resources.js
CHANGED
|
@@ -27,7 +27,7 @@ const RESOURCES = [
|
|
|
27
27
|
uri: 'umtri://rules/trunk-kinds',
|
|
28
28
|
file: 'trunk-kinds.md',
|
|
29
29
|
title: 'Trunk kinds — how each kind of trunk branches',
|
|
30
|
-
description: '
|
|
30
|
+
description: 'Six trunk kinds (front, server, database, library, device, tooling) recorded in metadata.kind, the limb axis for each, one common limb per trunk (metadata.common), the same domain name across trunks with no suffix, and how kinds connect. Read before creating a trunk or a limb directly under one, and when restructuring.',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
name: 'seasons',
|
package/core/tools.js
CHANGED
|
@@ -177,7 +177,7 @@ function compressProjects(projects, { descMode = 'excerpt' } = {}) {
|
|
|
177
177
|
openBugs: p.openBugs,
|
|
178
178
|
seasonCount: p.seasonCount,
|
|
179
179
|
};
|
|
180
|
-
// 팀 ground면 소속과 내 역할을 싣는다 — viewer면 쓰기 도구가 403으로 막힌다는 걸 미리 알 수 있게.
|
|
180
|
+
// 팀 ground면 소속과 내 역할을 싣는다 — viewer·reporter면 쓰기 도구가 403으로 막힌다는 걸 미리 알 수 있게.
|
|
181
181
|
// personal이면 생략(대부분의 ground가 personal이라 매번 실으면 잡음이다).
|
|
182
182
|
if (p.org && p.org.kind === 'team') {
|
|
183
183
|
out.org = p.org.slug;
|
|
@@ -230,6 +230,7 @@ function compressSeasons(seasons) {
|
|
|
230
230
|
// (2) sibling-cluster: 과밀한 limb/twig(잎 ≥4) 아래 접두어를 ≥3개 공유하는 잎 (twig 후보)
|
|
231
231
|
// — create_node의 parallel-leaves-need-twig와 임계값·정밀도 동일. "leaf 많음"만으론 발사 안 함
|
|
232
232
|
// (평평하지만 그룹 근거 없는 묶음은 정상). 구버전 junk-drawer-limb(무조건 ≥4)는 오발 잦아 제거.
|
|
233
|
+
// (3) thin-limb: 한 trunk의 limb 절반 이상이 잎 하나 이하일 때 (trunk 단위 1건)
|
|
233
234
|
export function computeIaHints(nodes, { edges = [], apis = [] } = {}) {
|
|
234
235
|
const hints = [];
|
|
235
236
|
const children = new Map();
|
|
@@ -299,6 +300,32 @@ export function computeIaHints(nodes, { edges = [], apis = [] } = {}) {
|
|
|
299
300
|
}
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
// (3) thin-limb — trunk 직속 가지(limb)인데 아래가 비었거나 잎 하나뿐인 것. 잎 하나 limb 자체는 허용이고,
|
|
304
|
+
// 같은 상위 개념의 작은 도메인이 limb 하나로 묶였는지 보라는 신호다. 도메인 이름을 trunk마다
|
|
305
|
+
// 맞추다 보면(trunk-kinds "같은 도메인, 같은 이름") 테이블 하나·라우트 하나짜리 limb이 trunk 수만큼
|
|
306
|
+
// 생긴다 — 실제 ground에서 limb의 절반이 이 모양이었다. 앱은 이런 limb을 접어 그리지만, 구조로는
|
|
307
|
+
// 여전히 한 단계가 정보 없이 끼어 있는 것이라 다듬을 후보로 올린다. 하나둘이면 정상(자라는 중)이라
|
|
308
|
+
// trunk 안 limb의 절반 이상일 때만, 그 trunk 단위로 한 번 말한다.
|
|
309
|
+
for (const trunk of nodes.filter((n) => !n.parent)) {
|
|
310
|
+
const limbs = (children.get(trunk.id) || []).filter((l) => l.type === 'limb');
|
|
311
|
+
if (limbs.length < 4) continue;
|
|
312
|
+
const thin = limbs.filter((l) => {
|
|
313
|
+
const kids = children.get(l.id) || [];
|
|
314
|
+
if (kids.length === 0) return !l.metadata?.placeholder;
|
|
315
|
+
return kids.length === 1 && !(children.get(kids[0].id) || []).length;
|
|
316
|
+
});
|
|
317
|
+
if (thin.length * 2 < limbs.length) continue;
|
|
318
|
+
hints.push({
|
|
319
|
+
rule: 'thin-limb',
|
|
320
|
+
trunkId: trunk.id,
|
|
321
|
+
trunkLabel: trunk.label,
|
|
322
|
+
limbCount: limbs.length,
|
|
323
|
+
thinCount: thin.length,
|
|
324
|
+
sampleIds: thin.slice(0, 8).map((l) => l.id),
|
|
325
|
+
suggestion: `${thin.length} of ${limbs.length} limbs under "${trunk.label}" hold a single leaf or nothing. Small domains that share a parent concept belong in one limb named for that concept, each kept as a named twig or leaf; a one-object domain with no parent concept stays a limb of its own. Never move a leaf directly under the trunk. See umtri://rules/trunk-kinds ("What earns a limb").`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
302
329
|
return hints;
|
|
303
330
|
}
|
|
304
331
|
|
|
@@ -335,7 +362,7 @@ export function registerTools(server, { api }) {
|
|
|
335
362
|
'list_projects',
|
|
336
363
|
{
|
|
337
364
|
title: 'List grounds (projects)',
|
|
338
|
-
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 that belongs to a team also carries org (the team slug) and role (owner/editor/viewer) — a viewer can read but every write tool returns 403 there. 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).',
|
|
365
|
+
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 that belongs to a team also carries org (the team slug) and role (owner/editor/reporter/viewer) — a viewer can read but every write tool returns 403 there; a reporter can only create_bug and update_bug. 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).',
|
|
339
366
|
inputSchema: z.object({
|
|
340
367
|
view: z.enum(['summary', 'full']).optional().describe('summary (default) for a compact list; full for every field.'),
|
|
341
368
|
}),
|
|
@@ -666,7 +693,7 @@ export function registerTools(server, { api }) {
|
|
|
666
693
|
'parent ∈ existing node id; omit to create a root-level node.',
|
|
667
694
|
'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.',
|
|
668
695
|
'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.',
|
|
669
|
-
'A trunk carries metadata.kind ∈ front · server · database · library · device, and its limbs follow that kind\'s axis: same domain name across trunks with no suffix ("예약", not "예약 API"), no technical-layer limbs, and at most one common limb (metadata.common=true). Read umtri://rules/trunk-kinds before creating a trunk or a limb under one.',
|
|
696
|
+
'A trunk carries metadata.kind ∈ front · server · database · library · device · tooling, and its limbs follow that kind\'s axis: same domain name across trunks with no suffix ("예약", not "예약 API"), no technical-layer limbs, and at most one common limb (metadata.common=true). Read umtri://rules/trunk-kinds before creating a trunk or a limb under one.',
|
|
670
697
|
'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).',
|
|
671
698
|
'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).',
|
|
672
699
|
'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. A trunk is never plan: metadata.plan on a trunk is rejected.',
|
|
@@ -876,17 +903,21 @@ export function registerTools(server, { api }) {
|
|
|
876
903
|
'record_commit',
|
|
877
904
|
{
|
|
878
905
|
title: 'Record a git commit onto the nodes it touched',
|
|
879
|
-
description: 'Configuration record (CI/CD). Given a commit SHA and the files it changed, finds nodes whose metadata.implements include any of those files and appends the commit (sha + timestamp + optional message) to their metadata.commits (deduped by sha). Returns the matched node ids. When one commit touches several nodes that are NOT yet connected, the response also carries coChangeCandidates[] ({a, aLabel, b, bLabel}) — nodes that change together are dependency candidates; review them and add a create_edge/create_api where a real relation exists (not auto-created; suppressed for large multi-node commits). Umtri does not run jobs or read git itself — a GitHub Action / CI step or an agent supplies the sha+files. Recording a commit is the LAST step: first make sure the change is in the tree (new unit → create_node with metadata.implements; moved file → update_node), otherwise the sha lands on the nodes that happen to exist and the new ones stay invisible. A repo that keeps forgetting should write the habit down — see umtri://rules/commit-sync. See also umtri://rules/plan.',
|
|
906
|
+
description: 'Configuration record (CI/CD). Given a commit SHA and the files it changed, finds nodes whose metadata.implements include any of those files and appends the commit (sha + timestamp + optional message) to their metadata.commits (deduped by sha). Returns the matched node ids. When one commit touches several nodes that are NOT yet connected, the response also carries coChangeCandidates[] ({a, aLabel, b, bLabel}) — nodes that change together are dependency candidates; review them and add a create_edge/create_api where a real relation exists (not auto-created; suppressed for large multi-node commits; pass coChange=false to skip them entirely when nobody will review, e.g. a CI step that only records). Umtri does not run jobs or read git itself — a GitHub Action / CI step or an agent supplies the sha+files. Recording a commit is the LAST step: first make sure the change is in the tree (new unit → create_node with metadata.implements; moved file → update_node), otherwise the sha lands on the nodes that happen to exist and the new ones stay invisible. A repo that keeps forgetting should write the habit down — see umtri://rules/commit-sync. See also umtri://rules/plan.',
|
|
880
907
|
inputSchema: z.object({
|
|
881
908
|
slug: z.string().min(1).describe('Ground slug.'),
|
|
882
909
|
sha: z.string().min(1).describe('Commit SHA (short or full).'),
|
|
883
910
|
files: z.array(z.string()).min(1).describe('Repo-relative paths changed by the commit, matched against nodes\' metadata.implements.'),
|
|
884
911
|
message: z.string().optional().describe('Commit message (optional, stored with the record).'),
|
|
912
|
+
coChange: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
|
|
913
|
+
.optional().describe('Default true. false skips coChangeCandidates — for callers that record every commit and never review the suggestions.'),
|
|
885
914
|
}),
|
|
886
915
|
},
|
|
887
|
-
async ({ slug, sha, files, message }) => {
|
|
916
|
+
async ({ slug, sha, files, message, coChange }) => {
|
|
888
917
|
try {
|
|
889
|
-
const
|
|
918
|
+
const payload = { sha, files, message };
|
|
919
|
+
if (coChange !== undefined) payload.coChange = coChange;
|
|
920
|
+
const res = await api.post(`/api/projects/${encodeURIComponent(slug)}/record-commit`, payload);
|
|
890
921
|
return jsonResult(res);
|
|
891
922
|
} catch (e) { return errorResult(e); }
|
|
892
923
|
},
|
|
@@ -1196,6 +1227,16 @@ export function registerTools(server, { api }) {
|
|
|
1196
1227
|
// 독립적으로도 산다 — 붙일 노드가 없다고 못 적게 하면 정작 필요한 초기에 못 쓴다.
|
|
1197
1228
|
// 아직 사람용 편집 화면이 없다: 지금은 이 도구들이 유일한 쓰기 경로다.
|
|
1198
1229
|
|
|
1230
|
+
// 쓰기 응답에서 body를 뺀다. 에이전트는 방금 보낸 본문을 돌려받을 이유가 없고, 수만 자 문서에서는
|
|
1231
|
+
// 이 응답이 전송 타임아웃을 부르는 쪽이었다. 길이(bodyChars)만 남기고, 다시 읽기는 get_wiki로.
|
|
1232
|
+
// REST(PUT /wiki)는 그대로 전체를 돌려준다 — 앱이 쓰는 응답 형태를 MCP 사정으로 바꾸지 않는다.
|
|
1233
|
+
function slimWikiWrite(written, warnings) {
|
|
1234
|
+
if (!written || typeof written !== 'object') return written;
|
|
1235
|
+
const { body, ...rest } = written;
|
|
1236
|
+
const out = { ...rest, bodyChars: (body ?? '').length };
|
|
1237
|
+
return warnings.length ? { ...out, warnings } : out;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1199
1240
|
server.registerTool(
|
|
1200
1241
|
'list_wiki',
|
|
1201
1242
|
{
|
|
@@ -1226,18 +1267,33 @@ export function registerTools(server, { api }) {
|
|
|
1226
1267
|
server.registerTool(
|
|
1227
1268
|
'get_wiki',
|
|
1228
1269
|
{
|
|
1229
|
-
title: 'Read one wiki page',
|
|
1230
|
-
description:
|
|
1270
|
+
title: 'Read one wiki page — in full, as an outline, one section, or the lines around a phrase',
|
|
1271
|
+
description: [
|
|
1272
|
+
'Returns one wiki page. `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. Every response carries rev (the current revision, what you pass as baseRev when you write) and bodyChars.',
|
|
1273
|
+
'By default the whole body comes back. **Pages run long (tens of thousands of characters); do not read one in full to answer a small question.** view="outline" returns the lead paragraph and the heading tree (path like "1.2", level, heading, line, chars per section) — start there on a page you have not seen. section="<heading text or path>" returns just that section (through its sub-headings), plus the footnote definitions it references; it must match exactly one heading, and on an ambiguous match the error lists the candidates with their paths. find="<text>" returns the lines containing the text (case-insensitive, literal) with context lines around each, merged into blocks with line numbers — a block is ready to be the oldString of edit_wiki. find inside one section: pass both.',
|
|
1274
|
+
'Read in full before write_wiki mode="replace" — it overwrites the body wholesale, and an outline is not the body. For a one-passage fix, find or section the spot and call edit_wiki instead.',
|
|
1275
|
+
'Pass rev=<n> to read an older revision instead of the current page (list them with list_wiki_revisions; outline/section/find work there too). 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.',
|
|
1276
|
+
].join(' '),
|
|
1231
1277
|
inputSchema: z.object({
|
|
1232
1278
|
slug: z.string().min(1).describe('Ground slug.'),
|
|
1233
1279
|
ref: z.string().min(1).describe('Page slug (e.g. "deploy-notes") or id (wiki-<uuid>).'),
|
|
1234
1280
|
rev: z.coerce.number().int().positive().optional().describe('Revision number to read. Omit for the current page.'),
|
|
1281
|
+
view: z.enum(['full', 'outline']).optional().describe('"full" (default) returns the body; "outline" returns the lead and the heading tree with sizes instead. Ignored when section or find is given.'),
|
|
1282
|
+
section: z.string().min(1).optional().describe('One heading, by its text (case-insensitive; backticks/emphasis ignored) or by its outline path ("1.2"). Returns that section and its sub-sections only.'),
|
|
1283
|
+
find: z.string().min(1).optional().describe('Literal text to look for (case-insensitive). Returns matching lines with context, merged into blocks with line numbers. With section, searches inside that section only.'),
|
|
1284
|
+
context: z.coerce.number().int().min(0).max(10).optional().describe('Lines of context around each find match. Default 2.'),
|
|
1235
1285
|
}),
|
|
1236
1286
|
},
|
|
1237
|
-
async ({ slug, ref, rev }) => {
|
|
1287
|
+
async ({ slug, ref, rev, view, section, find, context }) => {
|
|
1238
1288
|
try {
|
|
1239
1289
|
const base = `/api/projects/${encodeURIComponent(slug)}/wiki/${encodeURIComponent(ref)}`;
|
|
1240
|
-
|
|
1290
|
+
const qs = [];
|
|
1291
|
+
if (view) qs.push(`view=${view}`);
|
|
1292
|
+
if (section !== undefined) qs.push(`section=${encodeURIComponent(section)}`);
|
|
1293
|
+
if (find !== undefined) qs.push(`find=${encodeURIComponent(find)}`);
|
|
1294
|
+
if (context !== undefined) qs.push(`context=${context}`);
|
|
1295
|
+
const url = (rev ? `${base}/revisions/${rev}` : base) + (qs.length ? `?${qs.join('&')}` : '');
|
|
1296
|
+
return jsonResult(await api.get(url));
|
|
1241
1297
|
} catch (e) { return errorResult(e); }
|
|
1242
1298
|
},
|
|
1243
1299
|
);
|
|
@@ -1279,12 +1335,12 @@ export function registerTools(server, { api }) {
|
|
|
1279
1335
|
'**The wiki is the project\'s encyclopedia: one page is one entry, and its body is the current spec and rules of that thing.** It is a tree read front to back: an overview page at the top, chapter pages under it, entries under those — place every page with parent (a page slug or id) and position (its order among siblings), and check list_wiki order="tree" to see where it belongs. For a new wiki, follow the table of contents in umtri://templates/wiki. The title is a plain noun for the thing in the reader\'s language (조직, 배포, Billing) — no dash, colon, parenthetical or sentence; the description goes in the lead. Give every page a kind — overview (the top page and chapter pages that introduce what is under them), concept (a product/domain concept or model), component (how one part of the system works), rule (a convention that must be kept), glossary (the project\'s vocabulary). Write it to this skeleton: a lead paragraph first (1–3 sentences saying what the thing is — not a heading, quote or list), then "## 스펙" / "## Spec" (how it works now; break it down with ###), then "## 규칙" / "## Rules" (invariants, prohibitions, required order). concept and component need Spec; rule needs Rules; overview and glossary are free-form after the lead. No other top-level (##) sections. Background, history, rejected alternatives and side notes go into footnotes — [^name] in the text, [^name]: … at the end — not into the body. A reason stays in the body only if someone changing the code would get it wrong without it.',
|
|
1280
1336
|
'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.',
|
|
1281
1337
|
'**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.',
|
|
1282
|
-
'mode="replace" (default) overwrites the whole body — read the page first, or you will drop what was there; this is the normal way to
|
|
1338
|
+
'mode="replace" (default) overwrites the whole body — read the page first, or you will drop what was there; this is the normal way to rewrite a page. **To change one passage of an existing page, use edit_wiki instead** — it takes the exact text and its replacement, so a one-line fix on a long page does not resend the whole body. 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.',
|
|
1283
1339
|
'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.',
|
|
1284
1340
|
'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.',
|
|
1285
1341
|
'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.',
|
|
1286
1342
|
'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.',
|
|
1287
|
-
'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.',
|
|
1343
|
+
'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. The body itself is not echoed back — bodyChars is its length now; re-read with get_wiki if you need the text.',
|
|
1288
1344
|
'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.',
|
|
1289
1345
|
'The response carries warnings[] when the page strays from the skeleton or reads like a log (status banners, dated headings, checklists, change-history sections, unmatched footnotes). The write is saved either way — fix what they point at and write again.',
|
|
1290
1346
|
'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.',
|
|
@@ -1330,7 +1386,41 @@ export function registerTools(server, { api }) {
|
|
|
1330
1386
|
// 여기선 도구 호출 마크업 유출만 더한다 — 그건 MCP 인자 직렬화 사고라 이 층의 일이다.
|
|
1331
1387
|
const warnings = (written?.warnings ?? [])
|
|
1332
1388
|
.concat(toolMarkupCheck({ title, body: written?.body }));
|
|
1333
|
-
return jsonResult(
|
|
1389
|
+
return jsonResult(slimWikiWrite(written, warnings));
|
|
1390
|
+
} catch (e) { return errorResult(e); }
|
|
1391
|
+
},
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
server.registerTool(
|
|
1395
|
+
'edit_wiki',
|
|
1396
|
+
{
|
|
1397
|
+
title: 'Change one passage of a wiki page',
|
|
1398
|
+
description: [
|
|
1399
|
+
'Replaces one passage of an existing wiki page without resending the body. Pass the exact text to replace (oldString) and what it becomes (newString) — the same contract as a code editor\'s Edit: oldString must occur **exactly once** on the page. If it is missing, or occurs more than once, nothing is written and the error says which; copy the text verbatim from get_wiki (whitespace and line breaks count) or include more surrounding text to make it unique. newString="" deletes the passage. Matching is literal, not a regex.',
|
|
1400
|
+
'This is the normal way to fix a sentence, update a value, or correct one rule on a page that already exists. Use write_wiki when you are composing a page or rewriting several sections at once, and mode="append" there when a section is genuinely missing.',
|
|
1401
|
+
'The edit is a revision like any other write: it folds into your previous write within 30 minutes unless newRevision=true, and baseRev refuses the edit when the page moved on since you read it. The response carries rev, coalesced, previousBodyChars and bodyChars, plus warnings[] when the page as a whole now strays from the skeleton — not the body.',
|
|
1402
|
+
'Requires a write-scope token.',
|
|
1403
|
+
].join(' '),
|
|
1404
|
+
inputSchema: z.object({
|
|
1405
|
+
slug: z.string().min(1).describe('Ground slug.'),
|
|
1406
|
+
page: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).describe('Page slug of an existing page.'),
|
|
1407
|
+
oldString: z.string().min(1).describe('The exact text to replace. Must occur exactly once on the page.'),
|
|
1408
|
+
newString: z.string().describe('The replacement text. "" deletes the passage.'),
|
|
1409
|
+
baseRev: z.coerce.number().int().positive().optional().describe('The revision you read. If the page has moved past it, the edit is refused.'),
|
|
1410
|
+
newRevision: z.union([z.boolean(), z.enum(['true', 'false']).transform(v => v === 'true')])
|
|
1411
|
+
.optional().describe('Force a new revision instead of folding into your last write.'),
|
|
1412
|
+
}),
|
|
1413
|
+
},
|
|
1414
|
+
async ({ slug, page, oldString, newString, baseRev, newRevision }) => {
|
|
1415
|
+
try {
|
|
1416
|
+
const payload = { slug: page, mode: 'edit', oldString, newString };
|
|
1417
|
+
if (baseRev !== undefined) payload.baseRev = baseRev;
|
|
1418
|
+
if (newRevision !== undefined) payload.newRevision = newRevision;
|
|
1419
|
+
const written = await api.put(`/api/projects/${encodeURIComponent(slug)}/wiki`, payload);
|
|
1420
|
+
// 마크업 유출 판정은 새로 들어간 조각에만 건다 — 본문 나머지는 이 호출이 쓴 게 아니다.
|
|
1421
|
+
const warnings = (written?.warnings ?? [])
|
|
1422
|
+
.concat(toolMarkupCheck({ newString }));
|
|
1423
|
+
return jsonResult(slimWikiWrite(written, warnings));
|
|
1334
1424
|
} catch (e) { return errorResult(e); }
|
|
1335
1425
|
},
|
|
1336
1426
|
);
|
package/package.json
CHANGED