@things-factory/ai-client-base 10.1.26 → 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.
Files changed (37) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.js +34 -8
  2. package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
  3. package/dist-server/service/assistant/abort-notice.d.ts +3 -1
  4. package/dist-server/service/assistant/abort-notice.js +20 -6
  5. package/dist-server/service/assistant/abort-notice.js.map +1 -1
  6. package/dist-server/service/assistant/assistant-chat.d.ts +6 -0
  7. package/dist-server/service/assistant/assistant-chat.js +1 -1
  8. package/dist-server/service/assistant/assistant-chat.js.map +1 -1
  9. package/dist-server/service/assistant/registry-assistant.d.ts +13 -0
  10. package/dist-server/service/assistant/registry-assistant.js +7 -2
  11. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  12. package/dist-server/service/capability.d.ts +17 -0
  13. package/dist-server/service/capability.js +49 -0
  14. package/dist-server/service/capability.js.map +1 -0
  15. package/dist-server/service/index.d.ts +1 -0
  16. package/dist-server/service/index.js +2 -0
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/tool-registry/registry.d.ts +8 -1
  19. package/dist-server/service/tool-registry/registry.js +50 -6
  20. package/dist-server/service/tool-registry/registry.js.map +1 -1
  21. package/dist-server/service/tool-registry/types.d.ts +26 -2
  22. package/dist-server/service/tool-registry/types.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -2
  25. package/server/service/agentic-loop/agentic-loop.ts +34 -8
  26. package/server/service/agentic-loop/proposal-key.test.ts +83 -0
  27. package/server/service/assistant/abort-notice.ts +33 -11
  28. package/server/service/assistant/assistant-chat.test.ts +24 -2
  29. package/server/service/assistant/assistant-chat.ts +7 -2
  30. package/server/service/assistant/registry-assistant.test.ts +43 -0
  31. package/server/service/assistant/registry-assistant.ts +17 -2
  32. package/server/service/capability.test.ts +52 -0
  33. package/server/service/capability.ts +58 -0
  34. package/server/service/index.ts +2 -0
  35. package/server/service/tool-registry/registry.test.ts +88 -7
  36. package/server/service/tool-registry/registry.ts +63 -8
  37. package/server/service/tool-registry/types.ts +27 -2
@@ -10,35 +10,57 @@
10
10
  * and the person acts on half of it. That failure is silent by construction, which is why the
11
11
  * notice belongs beside the loop rather than beside each product's prose.
12
12
  *
13
- * ── Known debt: the strings are Korean ─────────────────────────────────────────
14
- * They were Korean in board-ai and they still are. Moving them does not make that worse, but a
15
- * framework package is the wrong home for untranslated user-facing text. When this needs a second
16
- * locale, the shape is a caller-supplied translator, not a table in here — the notice is one line
17
- * appended to a reply the product already owns.
13
+ * ── The words are the reader's, and the caller owns the reader (2026-09-18) ────────
14
+ * These lines were Korean literals in a framework package, so every product said them in Korean
15
+ * whatever language the person was reading in (ADR-0049 ② · ADR-0054). The notice now names a key
16
+ * and carries an English default; a caller that has the request's translator hands it in and the
17
+ * person reads the line in their own language.
18
18
  */
19
19
  import type { AgenticLoopResult } from '../agentic-loop/index.js'
20
20
  import { isTransientAIError } from '../utils/index.js'
21
21
 
22
+ /** Turns a key and its values into the reader's sentence. `ai-assistant` hands in the request's `context.t`. */
23
+ export type TranslateNotice = (key: string, params?: Record<string, any>) => string
24
+
25
+ /** The English sentence for each reason, used when the caller has no translator. */
26
+ const NOTICE_DEFAULTS: Record<string, string> = {
27
+ 'ai-assistant.notice.stopped': 'the request stopped partway, so only part of it was done.',
28
+ 'ai-assistant.notice.provider-busy': 'the ai service did not answer just now. try again in a moment.',
29
+ 'ai-assistant.notice.provider-error': 'something went wrong while handling this. tell an administrator if it keeps happening.',
30
+ 'ai-assistant.notice.too-long': 'this ran long and stopped at step {iter}. try asking in smaller pieces.',
31
+ 'ai-assistant.notice.tool-kept-failing': '{tool} failed {count} times in a row, so it stopped. try saying it differently.'
32
+ }
33
+
34
+ function line(key: string, params: Record<string, any>, t?: TranslateNotice): string {
35
+ const said = t
36
+ ? t(key, { defaultValue: NOTICE_DEFAULTS[key], ...params })
37
+ : Object.entries(params).reduce((text, [k, v]) => text.replaceAll(`{${k}}`, String(v)), NOTICE_DEFAULTS[key])
38
+ return `\n\n_(${said})_`
39
+ }
40
+
22
41
  /**
23
42
  * 중단 이유 → 답에 덧붙일 한 줄. 정상 종료면 빈 문자열이다.
24
43
  *
25
44
  * 답을 고치지 않고 **뒤에 붙인다** — 모델이 한 말을 우리가 다시 쓰지 않는다.
26
45
  */
27
- export function formatAbortNotice(abortReason: AgenticLoopResult<any>['abortReason']): string {
46
+ export function formatAbortNotice(
47
+ abortReason: AgenticLoopResult<any>['abortReason'],
48
+ t?: TranslateNotice
49
+ ): string {
28
50
  if (!abortReason) return ''
29
51
  switch (abortReason.type) {
30
52
  case 'aborted':
31
- return '\n\n_(요청이 중단되어 일부만 처리됐어요.)_'
53
+ return line('ai-assistant.notice.stopped', {}, t)
32
54
  case 'provider_error':
33
55
  /* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다 — "Error fetching from undefined: [503 …]" 은
34
56
  * 사용자가 할 수 있는 일을 알려주지 않는다. 일시 장애는 이미 몇 번 다시 시도한 뒤이므로,
35
57
  * 지금 필요한 안내는 "잠시 후 다시" 하나다. 원문은 서버 로그에 남는다. */
36
58
  return isTransientAIError({ message: abortReason.message })
37
- ? '\n\n_(AI 서비스가 일시적으로 응답하지 않았어요. 잠시 후 다시 시도해 주세요.)_'
38
- : '\n\n_(처리 중 오류가 발생했어요. 같은 요청이 계속 실패하면 관리자에게 알려 주세요.)_'
59
+ ? line('ai-assistant.notice.provider-busy', {}, t)
60
+ : line('ai-assistant.notice.provider-error', {}, t)
39
61
  case 'max_iterations':
40
- return `\n\n_(작업이 너무 길어 ${abortReason.iter}회에서 중단했어요. 더 작게 나눠 요청해보세요.)_`
62
+ return line('ai-assistant.notice.too-long', { iter: abortReason.iter }, t)
41
63
  case 'repeated_validation_failure':
42
- return `\n\n_(\`${abortReason.toolName}\` 호출이 ${abortReason.consecutive}회 연속 실패해 중단했어요. 요청을 다시 표현해보세요.)_`
64
+ return line('ai-assistant.notice.tool-kept-failing', { tool: abortReason.toolName, count: abortReason.consecutive }, t)
43
65
  }
44
66
  }
@@ -76,7 +76,7 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
76
76
  })
77
77
  )
78
78
  expect(r.loop.abortReason?.type).toBe('provider_error')
79
- expect(r.reply).toMatch(/다시/)
79
+ expect(r.reply).toMatch(/try again in a moment/)
80
80
  /* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다. */
81
81
  expect(r.reply).not.toMatch(/503/)
82
82
  expect(r.reply).not.toMatch(/Error fetching/)
@@ -94,7 +94,29 @@ describe('assistantChat — ① 중단 이유가 답에 닿는다', () => {
94
94
  )
95
95
  )
96
96
  expect(r.loop.abortReason?.type).toBe('max_iterations')
97
- expect(r.reply).toMatch(/중단/)
97
+ /* No translator was handed in, so the line falls back to English. */
98
+ expect(r.reply).toMatch(/stopped at step 2/)
99
+ })
100
+
101
+ it('★ 통지의 말은 읽는 사람의 것이다 — 번역기를 주면 그것이 쓴 문장이 붙는다', async () => {
102
+ const asked: any[] = []
103
+ const r = await assistantChat(
104
+ input(
105
+ {
106
+ options: { systemPrompt: 's', maxIterations: 2 },
107
+ chat: async () => okResult(undefined, [TC('read1')]),
108
+ t: (key: string, params: any) => {
109
+ asked.push({ key, params })
110
+ return `멈췄습니다(${params.iter}단계)`
111
+ }
112
+ },
113
+ { isReadTool: () => true }
114
+ )
115
+ )
116
+ expect(asked[0].key).toBe('ai-assistant.notice.too-long')
117
+ expect(asked[0].params.iter).toBe(2)
118
+ expect(asked[0].params.defaultValue).toMatch(/stopped at step/)
119
+ expect(r.reply).toContain('멈췄습니다(2단계)')
98
120
  })
99
121
 
100
122
  it('통지는 답을 고치지 않고 뒤에 붙는다', async () => {
@@ -36,9 +36,14 @@
36
36
  */
37
37
  import { runAgenticLoop, type AgenticLoopInput, type AgenticLoopResult } from '../agentic-loop/index.js'
38
38
  import { buildGroundedCorpus, groundingWarnings, type GroundingSources } from '../utils/index.js'
39
- import { formatAbortNotice } from './abort-notice.js'
39
+ import { formatAbortNotice, type TranslateNotice } from './abort-notice.js'
40
40
 
41
41
  export interface AssistantChatInput<TCtx = unknown, TOp = unknown> extends AgenticLoopInput<TCtx, TOp> {
42
+ /**
43
+ * The request's translator, so a notice this function appends is in the reader's language
44
+ * (ADR-0054). Without it the notice falls back to English.
45
+ */
46
+ t?: TranslateNotice
42
47
  /**
43
48
  * 이 턴에 모델이 본 것 — **도구 결과 말고** 전부. 도구 결과는 이 함수가 루프에서 받아 더한다.
44
49
  *
@@ -86,7 +91,7 @@ export async function assistantChat<TCtx = unknown, TOp = unknown>(
86
91
  const loop = await runAgenticLoop<TCtx, TOp>(loopInput)
87
92
 
88
93
  const baseText = loop.lastText ?? ''
89
- const reply = baseText + formatAbortNotice(loop.abortReason)
94
+ const reply = baseText + formatAbortNotice(loop.abortReason, input.t)
90
95
 
91
96
  let warnings: string[] | undefined
92
97
  if (!skipGrounding && baseText) {
@@ -307,3 +307,46 @@ describe('a client that cannot call tools', () => {
307
307
  await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
308
308
  })
309
309
  })
310
+
311
+ /*
312
+ * 도구가 「어느 대화에서 불렸나」를 안다 — ADR-0061 결정 3.
313
+ *
314
+ * 이 칸은 A2(모드를 서버가 등록한다)가 서기 전까지 비어 있었고 주석만 「A2 가 서면」이라고 적혀 있었다.
315
+ * A2 가 선 뒤에도 채우는 쪽이 없어서 도구는 계속 `host` 만 봤다 — 그것은 클라이언트가 채우는 칸이다.
316
+ */
317
+ describe('the tool context carries the conversation', () => {
318
+ it('★ 부르는 쪽이 준 mode 가 도구에게 간다 — 서버의 값이다', async () => {
319
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
320
+
321
+ await registryAssistantChat(
322
+ input({ client, toolCategories: ['figure-authoring'], mode: { kind: 'twin.space', target: 'SITE-1' } })
323
+ )
324
+
325
+ expect(ran[0].context.mode).toEqual({ kind: 'twin.space', target: 'SITE-1' })
326
+ })
327
+
328
+ it('★ 그 값은 `host` 와 섞이지 않는다 — 하나는 서버가, 하나는 클라이언트가 채운다', async () => {
329
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
330
+
331
+ await registryAssistantChat(
332
+ input({
333
+ client,
334
+ toolCategories: ['figure-authoring'],
335
+ hostContext: { figureId: 'fig-1' },
336
+ mode: { kind: 'figure.modeller' }
337
+ })
338
+ )
339
+
340
+ expect(ran[0].context).toEqual(
341
+ toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', host: { figureId: 'fig-1' }, mode: { kind: 'figure.modeller' } })
342
+ )
343
+ })
344
+
345
+ it('mode 를 안 주면 그 칸은 없다 — 빈 값을 지어내지 않는다', async () => {
346
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
347
+
348
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
349
+
350
+ expect(ran[0].context).not.toHaveProperty('mode')
351
+ })
352
+ })
@@ -49,6 +49,14 @@ export interface RegistryAssistantInput {
49
49
  toolAccess?: ToolAccessCheck
50
50
  /** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
51
51
  hostContext?: any
52
+ /**
53
+ * Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).
54
+ *
55
+ * Tools see it as `context.mode`. It is the server's value, resolved from the registry before the
56
+ * turn runs, so a tool that behaves differently in two conversations reads it here and not from
57
+ * `host`, which the client fills.
58
+ */
59
+ mode?: { kind: string; target?: string }
52
60
  /** Caller's koa state (domain, user, tx). Tools that read the database need it. */
53
61
  state: any
54
62
  model?: string
@@ -56,6 +64,8 @@ export interface RegistryAssistantInput {
56
64
  maxTokens?: number
57
65
  /** Make the model call a tool before it may answer. For surfaces that report live state. */
58
66
  requireToolOnFirstTurn?: boolean
67
+ /** The request's translator — the stop notice is then in the reader's language. */
68
+ t?: (key: string, params?: Record<string, any>) => string
59
69
  /** Texts the answer is allowed to draw identifiers from, besides the tool results. */
60
70
  groundingSources?: Omit<GroundingSources, 'toolResults'>
61
71
  }
@@ -104,7 +114,11 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
104
114
  const guidance = toolScopeGuidance(scope)
105
115
  const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
106
116
 
107
- const toolContext: ToolCallContext = { state, host: hostContext ?? {} }
117
+ /*
118
+ * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
119
+ * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
120
+ */
121
+ const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }
108
122
 
109
123
  const result = await assistantChat<ToolCallContext, unknown>({
110
124
  initialConversation: messages,
@@ -135,7 +149,8 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
135
149
  toolCallToOp: () => null,
136
150
  summarizeToolResult
137
151
  },
138
- groundingSources: input.groundingSources
152
+ groundingSources: input.groundingSources,
153
+ t: input.t
139
154
  })
140
155
 
141
156
  return { ...result, offeredTools: tools.map(tool => tool.name) }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * What this guards: a screen can ask whether this deployment has an AI, instead of finding out by
3
+ * pressing a button that fails.
4
+ *
5
+ * The defect this replaces: twin decided the same question from whether a mutation had thrown once.
6
+ * The mutation stopped being called, the flag stayed true, and deployments with no provider showed
7
+ * the button anyway (standard A7-a).
8
+ */
9
+ const mockConfigGet = jest.fn()
10
+ jest.mock('@things-factory/env', () => ({ config: { get: (...args: any[]) => mockConfigGet(...args) } }))
11
+
12
+ import { aiCapability } from './capability'
13
+ import { registerAIProvider, setDefaultAIClient } from './factory'
14
+
15
+ beforeEach(() => {
16
+ mockConfigGet.mockReset()
17
+ setDefaultAIClient(undefined as any)
18
+ })
19
+
20
+ it('★ a configured deployment says so, and names what answers', () => {
21
+ mockConfigGet.mockReturnValue(null)
22
+ setDefaultAIClient({ id: 'anthropic:claude-sonnet-5' } as any)
23
+
24
+ expect(aiCapability()).toEqual({ state: 'configured', provider: 'anthropic', model: 'claude-sonnet-5' })
25
+ })
26
+
27
+ it('★ no configuration at all is "not configured", with the reason — never silence', () => {
28
+ mockConfigGet.mockReturnValue(null)
29
+
30
+ expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-config' })
31
+ })
32
+
33
+ it('a configuration naming a provider nobody registered says which one', () => {
34
+ mockConfigGet.mockReturnValue({ provider: 'nonesuch', model: 'x' })
35
+
36
+ expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'nonesuch', reason: 'unknown-provider' })
37
+ })
38
+
39
+ it('a configuration with no provider field is its own reason', () => {
40
+ mockConfigGet.mockReturnValue({ model: 'x' })
41
+
42
+ expect(aiCapability()).toEqual({ state: 'not-configured', reason: 'no-provider' })
43
+ })
44
+
45
+ it('★ a provider whose adapter throws is not configured — it does not throw at the caller', () => {
46
+ registerAIProvider('explodes', () => {
47
+ throw new Error('no api key')
48
+ })
49
+ mockConfigGet.mockReturnValue({ provider: 'explodes', model: 'x' })
50
+
51
+ expect(aiCapability()).toEqual({ state: 'not-configured', provider: 'explodes', model: 'x', reason: 'no-adapter' })
52
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Is there an AI on this deployment, and what is missing — asked before anything is tried.
3
+ *
4
+ * The twin had a button that asked the assistant, and whether to show it was decided by whether a
5
+ * now-dead mutation had failed once. When the dock moved to the neutral door nothing called that
6
+ * mutation any more, so the answer was "configured" forever: the button appeared on deployments
7
+ * with no provider at all and failed when pressed (standard A7-a).
8
+ *
9
+ * A capability is read, not discovered by failing (ADR-0042 decision ②). It lives here because the
10
+ * provider configuration does, and the answer has three states — the third is the one a screen
11
+ * must not fold away: **not asked yet** is not "configured".
12
+ */
13
+ import { config as envConfig } from '@things-factory/env'
14
+
15
+ import { getDefaultAIClient, listRegisteredProviders } from './factory.js'
16
+
17
+ export type AiCapabilityState = 'configured' | 'not-configured'
18
+
19
+ export interface AiCapability {
20
+ state: AiCapabilityState
21
+ /** Which provider answers, when one does — for the person reading a boot log, not for a screen. */
22
+ provider?: string
23
+ /** The default model this deployment would use. */
24
+ model?: string
25
+ /** Why it is not configured, as a code: no-config · no-provider · unknown-provider · no-adapter. */
26
+ reason?: string
27
+ }
28
+
29
+ /**
30
+ * What this server can do, as of now.
31
+ *
32
+ * Creating the client is what proves a provider adapter is loaded and the configuration parses, so
33
+ * this asks for it. It never throws: a deployment with no AI is a normal deployment.
34
+ */
35
+ export function aiCapability(): AiCapability {
36
+ const client = safeDefaultClient()
37
+ if (client) {
38
+ const [provider, model] = String(client.id ?? '').split(':')
39
+ return { state: 'configured', provider: provider || undefined, model: model || undefined }
40
+ }
41
+
42
+ const cfg = envConfig.get('aiClient', null) as { provider?: string; model?: string } | null
43
+ if (!cfg) return { state: 'not-configured', reason: 'no-config' }
44
+ if (!cfg.provider) return { state: 'not-configured', reason: 'no-provider' }
45
+ if (!listRegisteredProviders().includes(cfg.provider)) {
46
+ return { state: 'not-configured', provider: cfg.provider, reason: 'unknown-provider' }
47
+ }
48
+ return { state: 'not-configured', provider: cfg.provider, model: cfg.model, reason: 'no-adapter' }
49
+ }
50
+
51
+ function safeDefaultClient() {
52
+ try {
53
+ return getDefaultAIClient()
54
+ } catch {
55
+ /* A broken configuration is "not configured", and it is the reason below that says which. */
56
+ return undefined
57
+ }
58
+ }
@@ -6,4 +6,6 @@ export * from './tool-registry/index.js'
6
6
  export * from './agentic-loop/index.js'
7
7
  export * from './assistant/index.js'
8
8
  export * from './analysis-contract.js'
9
+ /* 「이 배포에 AI 가 있나」 — 시도해서 알아내지 않고 먼저 읽는다(표준 A7-a). */
10
+ export * from './capability.js'
9
11
  export * from './testing/tool-call-context.js'
@@ -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,21 +299,92 @@ 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'])
303
+ })
304
+
305
+ test('a declared tool leaves no warning', () => {
306
+ registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
307
+ expect(privilegeWarnings()).toEqual([])
308
+ })
309
+
310
+ /*
311
+ * 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
312
+ * plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
313
+ */
314
+ test('★ a read tool that says nothing about its door is recorded too', () => {
315
+ registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
316
+
317
+ expect(privilegeWarnings()).toEqual([
318
+ expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
319
+ ])
293
320
  })
294
321
 
295
- test('a declared tool and a read tool leave no warning', () => {
296
- registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }, { name: 'r1', kind: 'read' }]))
297
- expect(getToolRegistryWarnings()).toEqual([])
322
+ test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
323
+ registerToolCategory({
324
+ name: 'surface-only',
325
+ guidance: '- fixture guidance for surface-only',
326
+ specs: [
327
+ { kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
328
+ ]
329
+ })
330
+
331
+ expect(privilegeWarnings()).toEqual([])
332
+ })
333
+
334
+ test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
335
+ registerToolCategory({
336
+ name: 'waiting',
337
+ guidance: '- fixture guidance for waiting',
338
+ specs: [
339
+ { kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
340
+ ]
341
+ })
342
+
343
+ expect(privilegeWarnings()).toEqual([
344
+ expect.objectContaining({
345
+ code: 'tool-door-has-no-privilege',
346
+ params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
347
+ })
348
+ ])
349
+ })
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([])
298
376
  })
299
377
 
300
378
  test('re-registering a category replaces its warnings instead of piling them up', () => {
301
379
  const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
302
380
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
381
+ const once = getToolRegistryWarnings().map(w => w.code).sort()
382
+
303
383
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
304
- expect(getToolRegistryWarnings()).toHaveLength(1)
384
+ /* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
385
+ expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
305
386
 
306
387
  registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
307
- expect(getToolRegistryWarnings()).toEqual([])
388
+ expect(privilegeWarnings()).toEqual([])
308
389
  })
309
390
  })
@@ -31,8 +31,14 @@ const categories: Map<string, ToolCategory> = new Map()
31
31
 
32
32
  /** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
33
33
  export interface ToolRegistryWarning {
34
- code: 'tool-privilege-undeclared'
35
- params: { category: string; tool: string; kind: ToolSpec['kind'] }
34
+ /**
35
+ * `tool-privilege-undeclared` — nothing said which door this tool reaches.
36
+ * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet
37
+ * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
38
+ * this is a different fact from saying nothing, and the report keeps the two apart.
39
+ */
40
+ code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared'
41
+ params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }
36
42
  /** For a person reading it; the code and params are what callers act on. */
37
43
  message: string
38
44
  }
@@ -89,22 +95,71 @@ export function registerToolCategory(category: ToolCategory): void {
89
95
  * is empty across the apps, this becomes a refusal.
90
96
  */
91
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
+ */
92
110
  for (const spec of category.specs) {
93
- if (spec.kind !== 'read' && !(spec.privilege?.category && spec.privilege?.privilege)) {
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
+
122
+ for (const spec of category.specs) {
123
+ const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
124
+ if (declared) continue
125
+
126
+ /*
127
+ * An empty list says this tool reaches no door — the ones that hand back what the surface sent
128
+ * are that. Nothing to gate, nothing to report.
129
+ */
130
+ if (Array.isArray(spec.doors) && spec.doors.length === 0) continue
131
+
132
+ if (spec.doors?.length) {
133
+ /*
134
+ * The door is named and carries no privilege yet. The tool cannot be stricter than the door
135
+ * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
136
+ * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
137
+ */
94
138
  warnings.push({
95
- code: 'tool-privilege-undeclared',
96
- params: { category: category.name, tool: spec.name, kind: spec.kind },
139
+ code: 'tool-door-has-no-privilege',
140
+ params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
97
141
  message:
98
- `tool '${spec.name}' in category '${category.name}' is '${spec.kind}' but declares no privilege — ` +
99
- 'declare the same { category, privilege } its GraphQL door uses'
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)'
100
145
  })
146
+ continue
101
147
  }
148
+
149
+ warnings.push({
150
+ code: 'tool-privilege-undeclared',
151
+ params: { category: category.name, tool: spec.name, kind: spec.kind },
152
+ message:
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`)."
156
+ })
102
157
  }
103
158
 
104
159
  if (categories.has(category.name)) {
105
160
  console.warn(
106
161
  `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
107
- `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`
162
+ '(normal on a dev hot reload; in production it means the same category is registered twice)'
108
163
  )
109
164
  }
110
165
 
@@ -155,10 +155,35 @@ export interface ToolSpecBase {
155
155
  * The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
156
156
  *
157
157
  * Expected for `write`, `action` and `external` — registration records a
158
- * `tool-privilege-undeclared` warning for one without it (A1 stage one). A `read` tool without it is
159
- * offered on domain membership alone, and its builder must check what it reads.
158
+ * `tool-privilege-undeclared` warning for one without it (A1 stage one).
160
159
  */
161
160
  privilege?: ToolPrivilege
161
+
162
+ /**
163
+ * Which GraphQL doors this tool reaches — `['Query.twinNow']`,
164
+ * `['Query.productionSummary', 'Query.pendingSummary']`.
165
+ *
166
+ * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,
167
+ * only plant's tools asked whether the caller may read what they read; twin's and figure's
168
+ * checked the tenant and nothing else, so holding the conversation's own privilege was enough to
169
+ * reach their business data.
170
+ *
171
+ * **Always a list, even for one** — one shape to read. A tool that answers from several doors
172
+ * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):
173
+ * plant's factory overview answers from two, and asking for only one of them would hand back
174
+ * half of what the caller may not see.
175
+ *
176
+ * Three states, and the boot report keeps them apart:
177
+ * - doors and a privilege — the gate checks it
178
+ * - `doors: []` — this tool reaches no door at all. The ones that hand back what the surface
179
+ * sent (`getOpenScene`, `getStationScreen`) are this: there is nothing behind them to gate.
180
+ * - doors with no privilege — the doors themselves declare none yet (twin's queries, waiting
181
+ * on AUTH stage two). A tool cannot be stricter than the door it reaches.
182
+ *
183
+ * The privilege is **the door's**, copied, and a test in each app compares the copy with the
184
+ * directive on that field. When the schema can be read at boot, the copy goes away.
185
+ */
186
+ doors?: string[]
162
187
  }
163
188
 
164
189
  export interface ReadToolSpec extends ToolSpecBase {