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

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 (36) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.js +22 -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 +2 -0
  10. package/dist-server/service/assistant/registry-assistant.js +2 -1
  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 +27 -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 +21 -8
  26. package/server/service/agentic-loop/proposal-key.test.ts +52 -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.ts +4 -1
  31. package/server/service/capability.test.ts +52 -0
  32. package/server/service/capability.ts +58 -0
  33. package/server/service/index.ts +2 -0
  34. package/server/service/tool-registry/registry.test.ts +43 -2
  35. package/server/service/tool-registry/registry.ts +37 -8
  36. 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) {
@@ -56,6 +56,8 @@ export interface RegistryAssistantInput {
56
56
  maxTokens?: number
57
57
  /** Make the model call a tool before it may answer. For surfaces that report live state. */
58
58
  requireToolOnFirstTurn?: boolean
59
+ /** The request's translator — the stop notice is then in the reader's language. */
60
+ t?: (key: string, params?: Record<string, any>) => string
59
61
  /** Texts the answer is allowed to draw identifiers from, besides the tool results. */
60
62
  groundingSources?: Omit<GroundingSources, 'toolResults'>
61
63
  }
@@ -135,7 +137,8 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
135
137
  toolCallToOp: () => null,
136
138
  summarizeToolResult
137
139
  },
138
- groundingSources: input.groundingSources
140
+ groundingSources: input.groundingSources,
141
+ t: input.t
139
142
  })
140
143
 
141
144
  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'
@@ -292,11 +292,52 @@ describe('registry — a tool that changes something names its door (A1, stage o
292
292
  expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
293
293
  })
294
294
 
295
- test('a declared tool and a read tool leave no warning', () => {
296
- registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }, { name: 'r1', kind: 'read' }]))
295
+ test('a declared tool leaves no warning', () => {
296
+ registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
297
297
  expect(getToolRegistryWarnings()).toEqual([])
298
298
  })
299
299
 
300
+ /*
301
+ * 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
302
+ * plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
303
+ */
304
+ test('★ a read tool that says nothing about its door is recorded too', () => {
305
+ registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
306
+
307
+ expect(getToolRegistryWarnings()).toEqual([
308
+ expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
309
+ ])
310
+ })
311
+
312
+ test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
313
+ registerToolCategory({
314
+ name: 'surface-only',
315
+ guidance: '- fixture guidance for surface-only',
316
+ specs: [
317
+ { kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
318
+ ]
319
+ })
320
+
321
+ expect(getToolRegistryWarnings()).toEqual([])
322
+ })
323
+
324
+ test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
325
+ registerToolCategory({
326
+ name: 'waiting',
327
+ guidance: '- fixture guidance for waiting',
328
+ specs: [
329
+ { kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
330
+ ]
331
+ })
332
+
333
+ expect(getToolRegistryWarnings()).toEqual([
334
+ expect.objectContaining({
335
+ code: 'tool-door-has-no-privilege',
336
+ params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
337
+ })
338
+ ])
339
+ })
340
+
300
341
  test('re-registering a category replaces its warnings instead of piling them up', () => {
301
342
  const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
302
343
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
@@ -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'
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
  }
@@ -90,21 +96,44 @@ export function registerToolCategory(category: ToolCategory): void {
90
96
  */
91
97
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
92
98
  for (const spec of category.specs) {
93
- if (spec.kind !== 'read' && !(spec.privilege?.category && spec.privilege?.privilege)) {
99
+ const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
100
+ if (declared) continue
101
+
102
+ /*
103
+ * An empty list says this tool reaches no door — the ones that hand back what the surface sent
104
+ * are that. Nothing to gate, nothing to report.
105
+ */
106
+ if (Array.isArray(spec.doors) && spec.doors.length === 0) continue
107
+
108
+ if (spec.doors?.length) {
109
+ /*
110
+ * The door is named and carries no privilege yet. The tool cannot be stricter than the door
111
+ * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
112
+ * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
113
+ */
94
114
  warnings.push({
95
- code: 'tool-privilege-undeclared',
96
- params: { category: category.name, tool: spec.name, kind: spec.kind },
115
+ code: 'tool-door-has-no-privilege',
116
+ params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
97
117
  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'
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'
100
120
  })
121
+ continue
101
122
  }
123
+
124
+ warnings.push({
125
+ code: 'tool-privilege-undeclared',
126
+ params: { category: category.name, tool: spec.name, kind: spec.kind },
127
+ 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"
130
+ })
102
131
  }
103
132
 
104
133
  if (categories.has(category.name)) {
105
134
  console.warn(
106
135
  `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
107
- `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`
136
+ '(normal on a dev hot reload; in production it means the same category is registered twice)'
108
137
  )
109
138
  }
110
139
 
@@ -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 {