umtri-mcp 1.0.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/LICENSE +201 -0
- package/README.md +88 -0
- package/core/api-client.js +54 -0
- package/core/policy.js +376 -0
- package/core/resources/plan.md +49 -0
- package/core/resources/seasons-human-only.md +33 -0
- package/core/resources/system-structure.md +229 -0
- package/core/resources/transplant.md +63 -0
- package/core/resources/vision.md +46 -0
- package/core/resources/vocabulary-detailed.md +278 -0
- package/core/resources/vocabulary.md +180 -0
- package/core/resources.js +74 -0
- package/core/tools.js +950 -0
- package/package.json +51 -0
- package/server.js +54 -0
package/core/policy.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// MCP write-tool policy checks.
|
|
2
|
+
// Source of truth for the rules described in resources/vocabulary.md.
|
|
3
|
+
// The schema does NOT enforce these — the tool layer does (reject for hard rules, warn for heuristics).
|
|
4
|
+
|
|
5
|
+
const STRUCTURE_TYPES = new Set(['trunk', 'limb', 'twig']);
|
|
6
|
+
const OBJECT_TYPE = 'leaf';
|
|
7
|
+
const ACTION_TYPE = 'vein';
|
|
8
|
+
|
|
9
|
+
// Reserved domain tokens — these are first-class citizens (apis/bugs/seasons/edges tables),
|
|
10
|
+
// not node labels.
|
|
11
|
+
const RESERVED_DOMAINS = ['api', 'bug', 'season', 'edge', 'endpoint',
|
|
12
|
+
'버그', '시즌', '엣지', '엔드포인트'];
|
|
13
|
+
|
|
14
|
+
// Verb endings (Korean) that suggest a label describes an *action*, not an object.
|
|
15
|
+
// Used in the leaf-vs-vein heuristic. Extended 2026-05-25 with Sino-Korean action stems
|
|
16
|
+
// observed in real AI-authored datasets (충전/차감/환불/연장/조회/만료/갱신/…).
|
|
17
|
+
const VERB_ENDINGS = ['수정', '적용', '마이그레이션', '분리', '등록', '발송',
|
|
18
|
+
'핸들링', '생성', '삭제', '추가', '변환', '검증',
|
|
19
|
+
'동기화', '전송', '처리', '계산', '리사이즈', '동작',
|
|
20
|
+
'충전', '차감', '환불', '연장', '조회', '만료',
|
|
21
|
+
'갱신', '진입', '노출', '집계', '차단', '복구',
|
|
22
|
+
'업로드', '다운로드', '발급'];
|
|
23
|
+
|
|
24
|
+
// Trunk naming pattern: <name>-app / <name>-server / single lowercase word (db, www).
|
|
25
|
+
const TRUNK_PATTERN = /^[a-z][a-z0-9-]*(-app|-server|-service|-job|-cdn)$|^[a-z]{2,8}$/;
|
|
26
|
+
|
|
27
|
+
// Promotion signals — when a twig's description contains these, it might belong at limb level.
|
|
28
|
+
// Derived from sidebar/auth/ui-system limb promotions on 2026-05-21.
|
|
29
|
+
const PROMOTION_KEYWORDS = [
|
|
30
|
+
'전역',
|
|
31
|
+
'라우트 비종속',
|
|
32
|
+
'모든 라우트',
|
|
33
|
+
'전 화면',
|
|
34
|
+
'스플래시',
|
|
35
|
+
'전역 재사용',
|
|
36
|
+
'재사용 자산',
|
|
37
|
+
'global',
|
|
38
|
+
'route-independent',
|
|
39
|
+
'splash',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Implementation jargon — code-structure words that describe HOW, not the user-facing WHAT.
|
|
43
|
+
// A node label should name the information unit (e.g. "Bug log API"), not the code role
|
|
44
|
+
// ("Bugs router"). Surfaced via reserved-domain-substring? No — those are separate domain
|
|
45
|
+
// reservations. This rule catches generic code-architecture words.
|
|
46
|
+
const IMPLEMENTATION_JARGON = [
|
|
47
|
+
'router', 'handler', 'controller', 'middleware', 'wrapper',
|
|
48
|
+
'manager', 'bootstrap', 'helper', 'util', 'utils', 'factory',
|
|
49
|
+
'layer', 'tier', 'store',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// File-extension patterns that signal a leaf is named after a filename instead of an info unit.
|
|
53
|
+
const FILE_EXTENSION_PATTERN = /\.(js|jsx|ts|tsx|mjs|cjs|py|rb|go|rs|java|kt|swift|sql|css|scss|html)$/i;
|
|
54
|
+
// Slash in a label suggests a file path, not a concept.
|
|
55
|
+
const PATH_SEPARATOR_PATTERN = /\//;
|
|
56
|
+
|
|
57
|
+
export function roleOf(type) {
|
|
58
|
+
if (STRUCTURE_TYPES.has(type)) return 'structure';
|
|
59
|
+
if (type === OBJECT_TYPE) return 'object';
|
|
60
|
+
if (type === ACTION_TYPE) return 'action';
|
|
61
|
+
return 'structure'; // unknown type — conservative fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── individual checks ─────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function reservedDomainCheck(label) {
|
|
67
|
+
if (!label) return null;
|
|
68
|
+
const lower = label.trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
// Exact match — strong warning.
|
|
71
|
+
if (RESERVED_DOMAINS.includes(lower)) {
|
|
72
|
+
return {
|
|
73
|
+
rule: 'reserved-domain',
|
|
74
|
+
severity: 'warn',
|
|
75
|
+
message: `Label "${label}" is a reserved domain. Record it as an entry in the apis/bugs/seasons/edges table, not as a node label.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Substring — soft info (could be a UI component named after the domain).
|
|
80
|
+
for (const token of RESERVED_DOMAINS) {
|
|
81
|
+
if (lower.includes(token)) {
|
|
82
|
+
return {
|
|
83
|
+
rule: 'reserved-domain-substring',
|
|
84
|
+
severity: 'info',
|
|
85
|
+
message: `Label contains reserved word "${token}". Confirm this node represents a UI component or system unit, not the domain itself.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function trunkNamingCheck(type, label) {
|
|
93
|
+
if (type !== 'trunk') return null;
|
|
94
|
+
if (!label) return null;
|
|
95
|
+
if (TRUNK_PATTERN.test(label)) return null;
|
|
96
|
+
return {
|
|
97
|
+
rule: 'trunk-naming',
|
|
98
|
+
severity: 'info',
|
|
99
|
+
message: `Trunk label "${label}" doesn't fit system-unit naming (e.g. user-app, api-server, db). Trunks should be deployable systems, not domains.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function leafVsVeinCheck(type, label) {
|
|
104
|
+
if (!label) return null;
|
|
105
|
+
if (type !== 'leaf' && type !== 'vein') return null;
|
|
106
|
+
const tail = label.trim();
|
|
107
|
+
const matchedVerb = VERB_ENDINGS.find(v => tail.endsWith(v));
|
|
108
|
+
if (!matchedVerb) return null;
|
|
109
|
+
|
|
110
|
+
if (type === 'leaf') {
|
|
111
|
+
return {
|
|
112
|
+
rule: 'leaf-vs-vein-heuristic',
|
|
113
|
+
severity: 'info',
|
|
114
|
+
message: `Label ends with verb "${matchedVerb}". Consider type "vein" (action) instead of "leaf" (object).`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function dormancyHintCheck(type) {
|
|
121
|
+
// Heads-up at create time: structure-only nodes will render as dormant until a leaf/vein descendant exists.
|
|
122
|
+
// Reminds agents that limb→leaf direct is allowed when the unit is a single component.
|
|
123
|
+
if (type !== 'limb' && type !== 'twig') return null;
|
|
124
|
+
return {
|
|
125
|
+
rule: 'structure-without-children',
|
|
126
|
+
severity: 'info',
|
|
127
|
+
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 structure is allowed (see PRD-node-roles.md §위계 규칙: trunk→leaf, limb→leaf OK).`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function promotionHintCheck(type, description) {
|
|
132
|
+
// When a twig's description mentions global/route-independent/splash etc., suggest limb instead.
|
|
133
|
+
if (type !== 'twig') return null;
|
|
134
|
+
if (!description) return null;
|
|
135
|
+
const text = description.toLowerCase();
|
|
136
|
+
const matched = PROMOTION_KEYWORDS.find(k => text.includes(k.toLowerCase()));
|
|
137
|
+
if (!matched) return null;
|
|
138
|
+
return {
|
|
139
|
+
rule: 'twig-promotion-candidate',
|
|
140
|
+
severity: 'info',
|
|
141
|
+
message: `Description signal "${matched}" — this may belong at limb level rather than twig. Route-independent shells, distinct entry flows, and globally reused assets typically become sibling limbs (e.g. sidebar, auth, ui-system) rather than twigs nested in a domain limb.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function implementationJargonCheck(label) {
|
|
146
|
+
// Catches code-architecture words that describe HOW the node is implemented rather than WHAT it represents.
|
|
147
|
+
// Example: "Bugs router" → the user-facing thing is the bug log, the router is just Express plumbing.
|
|
148
|
+
if (!label) return null;
|
|
149
|
+
const lower = label.toLowerCase();
|
|
150
|
+
// Split on whitespace + hyphen so we catch suffix forms ("api-layer") and standalone ("router").
|
|
151
|
+
const tokens = lower.split(/[\s\-_/]+/).filter(Boolean);
|
|
152
|
+
const matched = IMPLEMENTATION_JARGON.find(jargon => tokens.includes(jargon));
|
|
153
|
+
if (!matched) return null;
|
|
154
|
+
return {
|
|
155
|
+
rule: 'implementation-jargon',
|
|
156
|
+
severity: 'info',
|
|
157
|
+
message: `Label "${label}" contains code-structure word "${matched.replace(/^-/, '')}". Names like router/handler/middleware/layer describe HOW the node is implemented, not WHAT it represents. Prefer the information unit a user would perceive (e.g. "Bug log API" instead of "Bugs router"). See system-structure resource — nodes are information units, not code files.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function fileAsLeafCheck(type, label) {
|
|
162
|
+
// Catches leafs labelled as filenames (`store.js`) or paths (`middleware/auth.js`).
|
|
163
|
+
// The file belongs in metadata.implements; the label should name the concept.
|
|
164
|
+
// Also flags the redundant-child antipattern: if a twig already groups a concept and its
|
|
165
|
+
// only child is a leaf named after the file, the twig should absorb the file directly.
|
|
166
|
+
if (type !== 'leaf') return null;
|
|
167
|
+
if (!label) return null;
|
|
168
|
+
const trimmed = label.trim();
|
|
169
|
+
const isFilename = FILE_EXTENSION_PATTERN.test(trimmed);
|
|
170
|
+
const isPath = PATH_SEPARATOR_PATTERN.test(trimmed);
|
|
171
|
+
if (!isFilename && !isPath) return null;
|
|
172
|
+
return {
|
|
173
|
+
rule: 'file-as-leaf',
|
|
174
|
+
severity: 'info',
|
|
175
|
+
message: `Leaf label "${label}" looks like a filename or path. Labels should name the information unit the user perceives; record the file path under metadata.implements. If the parent twig already groups this single concept (twig + single file leaf = redundant), consider absorbing the file into the parent twig's metadata.implements and removing this leaf.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 라벨 첫 토큰(접두어) — sibling-cluster(get_graph)와 동일 규칙.
|
|
180
|
+
function firstToken(label) {
|
|
181
|
+
const first = (label || '').trim().split(/[\s\-_/]+/)[0];
|
|
182
|
+
return first && first.length >= 2 ? first : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 과밀한(직속 leaf ≥4) limb/twig 아래에, 새 leaf 포함 같은 접두어가 ≥3개 모일 때만 발사.
|
|
186
|
+
// get_graph의 sibling-cluster 힌트와 임계값·정밀도를 일치 — 평평하지만 그룹지을 근거가 없는
|
|
187
|
+
// 묶음(예: doc pages, resources)에는 발사하지 않음. 단순 "leaf 많음"은 신호가 아님.
|
|
188
|
+
const NEEDS_TWIG_MIN_LEAVES = 4; // 과밀 기준(새 leaf 포함 총 직속 leaf)
|
|
189
|
+
const NEEDS_TWIG_MIN_CLUSTER = 3; // 같은 접두어 공유 최소 개수
|
|
190
|
+
export function parallelLeavesNeedTwigCheck({ parentType, type, label, siblingLeafLabels }) {
|
|
191
|
+
if (parentType !== 'limb' && parentType !== 'twig') return null;
|
|
192
|
+
if (type !== 'leaf') return null;
|
|
193
|
+
const labels = [label, ...(Array.isArray(siblingLeafLabels) ? siblingLeafLabels : [])];
|
|
194
|
+
if (labels.length < NEEDS_TWIG_MIN_LEAVES) return null;
|
|
195
|
+
const prefix = firstToken(label);
|
|
196
|
+
if (!prefix) return null;
|
|
197
|
+
const shared = labels.filter(l => firstToken(l) === prefix).length;
|
|
198
|
+
if (shared < NEEDS_TWIG_MIN_CLUSTER) return null;
|
|
199
|
+
return {
|
|
200
|
+
rule: 'parallel-leaves-need-twig',
|
|
201
|
+
severity: 'info',
|
|
202
|
+
message: `This ${parentType} already has ${labels.length} direct leaves, and ${shared} share the prefix "${prefix}". Those almost always belong under a twig "${prefix} …" — consider creating it and re-parenting them. (A flat ${parentType} without a shared sub-theme is fine; don't over-nest.)`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function missingImplementsCheck({ type, metadata }) {
|
|
207
|
+
// leaf/vein은 metadata.implements (파일 경로 배열) 갖는 게 컨벤션 — concept → code 매핑.
|
|
208
|
+
// 의도적 placeholder는 면제.
|
|
209
|
+
if (type !== 'leaf' && type !== 'vein') return null;
|
|
210
|
+
if (metadata?.placeholder === true) return null;
|
|
211
|
+
if (metadata?.implements) return null;
|
|
212
|
+
return {
|
|
213
|
+
rule: 'missing-implements',
|
|
214
|
+
severity: 'info',
|
|
215
|
+
message: `${type} without metadata.implements. Add the source path(s) so the graph maps concept → code. Example: metadata.implements = ["server/data/store.js"] or ["src/Modal.jsx#default"] for multi-export files. Skip only for intentionally-planned work (metadata.placeholder=true).`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function reparentMetadataHint(patch) {
|
|
220
|
+
// When reparenting via update_node, suggest recording the move in metadata for inspectable history.
|
|
221
|
+
if (!patch) return null;
|
|
222
|
+
if (!('parent' in patch)) return null;
|
|
223
|
+
if (patch.parent === null) return null; // detach-to-root has different semantics
|
|
224
|
+
const meta = patch.metadata || {};
|
|
225
|
+
const hasTrace = 'reparented_at' in meta || 'reparented_to' in meta || 'reparented_from' in meta;
|
|
226
|
+
if (hasTrace) return null;
|
|
227
|
+
return {
|
|
228
|
+
rule: 'reparent-metadata-hint',
|
|
229
|
+
severity: 'info',
|
|
230
|
+
message: 'Reparenting a node — consider adding metadata.reparented_at (ISO date), metadata.reparented_from (prior parent id), and metadata.reparented_to (new parent id) so the restructure history stays inspectable.',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── bug lifecycle ────────────────────────────────────────────────────
|
|
235
|
+
// Bug Codex reads status as three states: open=wild, in_progress=chasing,
|
|
236
|
+
// resolved|closed=catched. The recommended path is wild → chasing → catched:
|
|
237
|
+
// flip to in_progress the moment work starts, then resolve when it lands.
|
|
238
|
+
//
|
|
239
|
+
// Why this matters beyond tidiness: in_progress is the only signal that someone
|
|
240
|
+
// is already on a bug. An agent that fixes and resolves in one call never shows
|
|
241
|
+
// "chasing", so a second agent (or the human) has no way to see the work in
|
|
242
|
+
// flight and may start the same fix. The jump also collapses the two timestamps
|
|
243
|
+
// the codex uses to tell "found → started" from "started → shipped".
|
|
244
|
+
const CATCHED_STATUSES = new Set(['resolved', 'closed']);
|
|
245
|
+
|
|
246
|
+
export function bugStatusTransitionCheck(from, to) {
|
|
247
|
+
if (!from || !to || from === to) return null;
|
|
248
|
+
|
|
249
|
+
// wild → catched, skipping chasing.
|
|
250
|
+
if (from === 'open' && CATCHED_STATUSES.has(to)) {
|
|
251
|
+
return {
|
|
252
|
+
rule: 'bug-skips-chasing',
|
|
253
|
+
severity: 'info',
|
|
254
|
+
message: `Bug goes straight from "open" (wild) to "${to}" (catched), skipping "in_progress" (chasing). Recommended flow: set status="in_progress" when you start the fix, then "${to}" once it lands. in_progress is the only marker that someone is already on this bug — without it a parallel agent can't tell the work is in flight, and the codex loses the found → started → shipped timeline. If the fix was genuinely instant, this is fine as-is.`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Re-opening a caught bug — not wrong, but worth naming so it isn't a silent regression.
|
|
259
|
+
if (CATCHED_STATUSES.has(from) && to === 'open') {
|
|
260
|
+
return {
|
|
261
|
+
rule: 'bug-reopened',
|
|
262
|
+
severity: 'info',
|
|
263
|
+
message: `Bug moves from "${from}" (catched) back to "open" (wild) — it escaped. If you are resuming work rather than reporting a regression, "in_progress" (chasing) says so more precisely. If it is a regression, note what shipped and broke it in the description.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function validateBugUpdate({ currentStatus, patch }) {
|
|
271
|
+
const warnings = [];
|
|
272
|
+
if (patch && 'status' in patch) {
|
|
273
|
+
const s = bugStatusTransitionCheck(currentStatus, patch.status);
|
|
274
|
+
if (s) warnings.push(s);
|
|
275
|
+
}
|
|
276
|
+
return { warnings };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function hierarchyCheck(parentType, childType) {
|
|
280
|
+
if (!parentType) return null; // root-level
|
|
281
|
+
const parentRole = roleOf(parentType);
|
|
282
|
+
const childRole = roleOf(childType);
|
|
283
|
+
|
|
284
|
+
if (parentRole === 'action') {
|
|
285
|
+
return {
|
|
286
|
+
rule: 'hierarchy-vein-terminal',
|
|
287
|
+
severity: 'error',
|
|
288
|
+
message: 'A vein is terminal — it cannot have children. Pick a different parent.',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (parentRole === 'object' && childRole === 'structure') {
|
|
292
|
+
return {
|
|
293
|
+
rule: 'hierarchy-leaf-no-branch',
|
|
294
|
+
severity: 'warn',
|
|
295
|
+
message: 'A leaf (object) cannot branch back into structure (trunk/limb/twig). Pick a structure parent.',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (parentRole === 'structure' && childRole === 'action') {
|
|
299
|
+
return {
|
|
300
|
+
rule: 'hierarchy-skip-leaf',
|
|
301
|
+
severity: 'warn',
|
|
302
|
+
message: 'A vein is meant to live under a leaf (object), not directly under structure. Consider adding a leaf parent first.',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── composite validators ─────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
// Returns { ok, rejectReason, warnings[] }.
|
|
311
|
+
// rejectReason set when any check returns severity='error'.
|
|
312
|
+
// phase: 'create' enables dormancy hint (irrelevant on update where children may already exist).
|
|
313
|
+
// siblingLeafCount: # of active leaf siblings already under the resolved parent (for parallel-leaves rule).
|
|
314
|
+
// metadata: this node's metadata (for missing-implements rule).
|
|
315
|
+
export function validateNode({ type, label, parentType, description, metadata, siblingLeafCount, siblingLeafLabels, phase = 'create' }) {
|
|
316
|
+
const warnings = [];
|
|
317
|
+
let rejectReason = null;
|
|
318
|
+
|
|
319
|
+
const h = hierarchyCheck(parentType, type);
|
|
320
|
+
if (h) {
|
|
321
|
+
if (h.severity === 'error') rejectReason = h.message;
|
|
322
|
+
else warnings.push(h);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const r = reservedDomainCheck(label);
|
|
326
|
+
if (r) warnings.push(r);
|
|
327
|
+
|
|
328
|
+
const t = trunkNamingCheck(type, label);
|
|
329
|
+
if (t) warnings.push(t);
|
|
330
|
+
|
|
331
|
+
const l = leafVsVeinCheck(type, label);
|
|
332
|
+
if (l) warnings.push(l);
|
|
333
|
+
|
|
334
|
+
if (phase === 'create') {
|
|
335
|
+
const d = dormancyHintCheck(type);
|
|
336
|
+
if (d) warnings.push(d);
|
|
337
|
+
|
|
338
|
+
const pl = parallelLeavesNeedTwigCheck({ parentType, type, label, siblingLeafLabels });
|
|
339
|
+
if (pl) warnings.push(pl);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const mi = missingImplementsCheck({ type, metadata });
|
|
343
|
+
if (mi) warnings.push(mi);
|
|
344
|
+
|
|
345
|
+
const p = promotionHintCheck(type, description);
|
|
346
|
+
if (p) warnings.push(p);
|
|
347
|
+
|
|
348
|
+
const j = implementationJargonCheck(label);
|
|
349
|
+
if (j) warnings.push(j);
|
|
350
|
+
|
|
351
|
+
const f = fileAsLeafCheck(type, label);
|
|
352
|
+
if (f) warnings.push(f);
|
|
353
|
+
|
|
354
|
+
return { ok: !rejectReason, rejectReason, warnings };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Patch-level checks that don't depend on resolved node state (reparent trace, future cross-field hints).
|
|
358
|
+
export function validateUpdate({ patch }) {
|
|
359
|
+
const warnings = [];
|
|
360
|
+
|
|
361
|
+
const rh = reparentMetadataHint(patch);
|
|
362
|
+
if (rh) warnings.push(rh);
|
|
363
|
+
|
|
364
|
+
return { warnings };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function validateEdge({ sourceId, targetId }) {
|
|
368
|
+
if (sourceId === targetId) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
rejectReason: 'Edge source and target cannot be the same node.',
|
|
372
|
+
warnings: [],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return { ok: true, rejectReason: null, warnings: [] };
|
|
376
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Rule: Plan nodes are a node-based brief
|
|
2
|
+
|
|
3
|
+
In Umtri the human can express intent **as structure, not as a prompt**. In
|
|
4
|
+
Plan mode they draw the IA they want — nodes marked `plan` (the response shows
|
|
5
|
+
`plan: true` on such nodes; they render dashed/grayscale in the UI). That plan
|
|
6
|
+
subtree **is your brief**: where things should live (structure), what they are
|
|
7
|
+
(type/label), and what they should do (description).
|
|
8
|
+
|
|
9
|
+
Umtri itself does not edit code. You (the AI agent) are the executor: read the
|
|
10
|
+
brief here, build the real code with your own tools, then record what you built
|
|
11
|
+
back onto the nodes. The human commits when satisfied.
|
|
12
|
+
|
|
13
|
+
## The loop
|
|
14
|
+
|
|
15
|
+
1. **Read the brief.** Treat every `plan` node as an instruction. Use its
|
|
16
|
+
`description` as the intent detail (fetch `view="full"` when you need the
|
|
17
|
+
complete description, not just the excerpt).
|
|
18
|
+
2. **Realize.** Write the actual code in the repository with your own tools.
|
|
19
|
+
3. **Record (required).** On each plan node you realized, attach
|
|
20
|
+
`metadata.implements` = the real source path(s) you wrote
|
|
21
|
+
(e.g. `["server/routes/foo.js"]`, or `"path#identifier"` for multi-export
|
|
22
|
+
files) via `update_node`. **This is mandatory** — a plan node with no
|
|
23
|
+
`implements` cannot be committed later, so without it your work is not
|
|
24
|
+
considered realized.
|
|
25
|
+
4. **New detail stays plan.** If realizing a brief requires nodes the human
|
|
26
|
+
didn't draw (child leaves/veins, an extra twig), create them with
|
|
27
|
+
`metadata.plan = true` too — everything you add stays reviewable until the
|
|
28
|
+
human commits. Do not create finalized (non-plan) nodes during realization.
|
|
29
|
+
5. **Commit when realized.** Promote a plan node to the real tree with
|
|
30
|
+
`commit_plan` — it clears `metadata.plan`. The tool **rejects the commit
|
|
31
|
+
unless the node already has `metadata.implements`** (step 3), so attach the
|
|
32
|
+
source paths first. The human can also commit from the UI. If you are unsure
|
|
33
|
+
the realization is complete, leave it as plan and ask the human rather than
|
|
34
|
+
committing.
|
|
35
|
+
6. **Record the commit (CI/CD form record).** When the code lands in git, the
|
|
36
|
+
commit is recorded onto the nodes it touched via `record_commit`
|
|
37
|
+
(sha + changed files → appended to each matching node's `metadata.commits`).
|
|
38
|
+
Normally a GitHub Action / CI step does this automatically on merge; you may
|
|
39
|
+
also call `record_commit` directly when you know the sha and files.
|
|
40
|
+
|
|
41
|
+
## Boundaries
|
|
42
|
+
|
|
43
|
+
- Plan is independent of seasons; you still cannot create or modify seasons
|
|
44
|
+
(see umtri://rules/seasons-human-only).
|
|
45
|
+
- The brief is still an information-structure diagram, not a work log
|
|
46
|
+
(see umtri://rules/vocabulary) — realize the structure the human intends,
|
|
47
|
+
and name nodes as what they are.
|
|
48
|
+
- If the brief is ambiguous or seems wrong, ask the human rather than guessing —
|
|
49
|
+
the point of a node-based brief is a tight, reviewable contract.
|
|
@@ -0,0 +1,33 @@
|
|
|
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.
|