@things-factory/ai-client-base 10.1.28 → 10.1.29

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.
@@ -23,6 +23,16 @@ afterEach(() => {
23
23
  })
24
24
 
25
25
  // 헬퍼 — 카테고리 fixture
26
+ /**
27
+ * 권한 축의 경고만 — 문 축(`tool-doors-undeclared`)은 자기 시험이 따로 본다.
28
+ *
29
+ * 한 시험이 두 축을 함께 세면, 한 축에 코드가 하나 늘 때마다 다른 축의 시험이 빨개진다. 그러면 고치는
30
+ * 사람은 자기가 건드리지 않은 시험을 고치게 되고, 그 손질이 시험을 무디게 만든다.
31
+ */
32
+ function privilegeWarnings() {
33
+ return getToolRegistryWarnings().filter(w => w.code !== 'tool-doors-undeclared')
34
+ }
35
+
26
36
  function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
27
37
  return {
28
38
  name,
@@ -272,7 +282,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
272
282
  specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
273
283
  })
274
284
  expect(getToolKind('changesThings')).toBe(kind)
275
- expect(getToolRegistryWarnings()).toEqual([
285
+ expect(privilegeWarnings()).toEqual([
276
286
  expect.objectContaining({
277
287
  code: 'tool-privilege-undeclared',
278
288
  params: { category: 'no-door', tool: 'changesThings', kind }
@@ -289,12 +299,12 @@ describe('registry — a tool that changes something names its door (A1, stage o
289
299
  { kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
290
300
  ]
291
301
  })
292
- expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
302
+ expect(privilegeWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
293
303
  })
294
304
 
295
305
  test('a declared tool leaves no warning', () => {
296
306
  registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
297
- expect(getToolRegistryWarnings()).toEqual([])
307
+ expect(privilegeWarnings()).toEqual([])
298
308
  })
299
309
 
300
310
  /*
@@ -304,7 +314,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
304
314
  test('★ a read tool that says nothing about its door is recorded too', () => {
305
315
  registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
306
316
 
307
- expect(getToolRegistryWarnings()).toEqual([
317
+ expect(privilegeWarnings()).toEqual([
308
318
  expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
309
319
  ])
310
320
  })
@@ -318,7 +328,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
318
328
  ]
319
329
  })
320
330
 
321
- expect(getToolRegistryWarnings()).toEqual([])
331
+ expect(privilegeWarnings()).toEqual([])
322
332
  })
323
333
 
324
334
  test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
@@ -330,7 +340,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
330
340
  ]
331
341
  })
332
342
 
333
- expect(getToolRegistryWarnings()).toEqual([
343
+ expect(privilegeWarnings()).toEqual([
334
344
  expect.objectContaining({
335
345
  code: 'tool-door-has-no-privilege',
336
346
  params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
@@ -338,13 +348,43 @@ describe('registry — a tool that changes something names its door (A1, stage o
338
348
  ])
339
349
  })
340
350
 
351
+ /*
352
+ * 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** — 쓴 사람 본인까지.
353
+ *
354
+ * ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
355
+ * 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
356
+ * 「이 답을 볼 권한이 없습니다」로 보였다.
357
+ *
358
+ * 부팅은 그때 아무 말도 안 했다. 권한 검사가 `privilege` 가 있으면 건너뛰는 자리에서 문 검사까지
359
+ * 함께 건너뛰었기 때문이다 — 트윈의 저작 도구 넷이 정확히 그 모양이었다(권한 있음, 문 없음).
360
+ */
361
+ test('★ a tool that declares no doors is reported even when it declares a privilege', () => {
362
+ registerToolCategory(
363
+ makeCategory('authoring', [
364
+ { name: 'proposeSceneEdit', kind: 'write', privilege: { category: 'board', privilege: 'mutation' } }
365
+ ])
366
+ )
367
+
368
+ const doors = getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')
369
+ expect(doors.map(w => w.params.tool)).toEqual(['proposeSceneEdit'])
370
+ })
371
+
372
+ test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
373
+ registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
374
+
375
+ expect(getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')).toEqual([])
376
+ })
377
+
341
378
  test('re-registering a category replaces its warnings instead of piling them up', () => {
342
379
  const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
343
380
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
381
+ const once = getToolRegistryWarnings().map(w => w.code).sort()
382
+
344
383
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
345
- expect(getToolRegistryWarnings()).toHaveLength(1)
384
+ /* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
385
+ expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
346
386
 
347
387
  registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
348
- expect(getToolRegistryWarnings()).toEqual([])
388
+ expect(privilegeWarnings()).toEqual([])
349
389
  })
350
390
  })
@@ -37,7 +37,7 @@ export interface ToolRegistryWarning {
37
37
  * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
38
38
  * this is a different fact from saying nothing, and the report keeps the two apart.
39
39
  */
40
- code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege'
40
+ code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared'
41
41
  params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }
42
42
  /** For a person reading it; the code and params are what callers act on. */
43
43
  message: string
@@ -95,6 +95,30 @@ export function registerToolCategory(category: ToolCategory): void {
95
95
  * is empty across the apps, this becomes a refusal.
96
96
  */
97
97
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
98
+
99
+ /*
100
+ * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────
101
+ *
102
+ * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는
103
+ * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
104
+ * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
105
+ *
106
+ * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,
107
+ * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —
108
+ * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
109
+ */
110
+ for (const spec of category.specs) {
111
+ if (Array.isArray(spec.doors)) continue
112
+
113
+ warnings.push({
114
+ code: 'tool-doors-undeclared',
115
+ params: { category: category.name, tool: spec.name, kind: spec.kind },
116
+ message:
117
+ `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +
118
+ 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'
119
+ })
120
+ }
121
+
98
122
  for (const spec of category.specs) {
99
123
  const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
100
124
  if (declared) continue
@@ -115,8 +139,9 @@ export function registerToolCategory(category: ToolCategory): void {
115
139
  code: 'tool-door-has-no-privilege',
116
140
  params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
117
141
  message:
118
- `tool '${spec.name}' reaches ${spec.doors.join(' + ')}, which declare no privilege — ` +
119
- 'the tool is as open as the doors it reaches until they declare one'
142
+ `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +
143
+ 'whether those doors require anything is asked at boot, against the schema, not copied here ' +
144
+ '(ADR-0072 decision 5)'
120
145
  })
121
146
  continue
122
147
  }
@@ -125,8 +150,9 @@ export function registerToolCategory(category: ToolCategory): void {
125
150
  code: 'tool-privilege-undeclared',
126
151
  params: { category: category.name, tool: spec.name, kind: spec.kind },
127
152
  message:
128
- `tool '${spec.name}' in category '${category.name}' says nothing about the doors it reaches — ` +
129
- "declare `doors` with the same { category, privilege } those doors use, or `doors: []` when it reaches none"
153
+ `tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +
154
+ 'a tool that changes something says who may run it. What the doors it reaches require is a ' +
155
+ "separate question, answered at boot against the schema (`tool-door-not-in-schema`)."
130
156
  })
131
157
  }
132
158