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

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.
@@ -1,7 +1,7 @@
1
1
  import { type ToolCategoryFilter } from '../tool-registry/index.js';
2
- import type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js';
3
- /** Answers "may this caller use a door guarded by this privilege". */
4
- export type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>;
2
+ import type { ToolSpec } from '../tool-registry/types.js';
3
+ /** Answers "may this caller pass this door" — `'Query.jobOrders'`. */
4
+ export type ToolAccessCheck = (door: string) => Promise<boolean>;
5
5
  export interface ScopedCategory {
6
6
  readonly name: string;
7
7
  readonly guidance: string;
@@ -10,36 +10,53 @@ exports.toolScopeGuidance = toolScopeGuidance;
10
10
  *
11
11
  * ── Two questions, one list ───────────────────────────────────────────────────────
12
12
  * Which categories did this surface ask for? `toolCategories`
13
- * Which of their tools may this caller use? each spec's `privilege`, asked of `access`
13
+ * Which of their tools may this caller use? each spec's `doors`, asked of `access`
14
14
  *
15
15
  * **Nothing asked means nothing offered.** Omitting the category list used to mean "every
16
16
  * registered category"; that default is the one that opened board-import's Board-creating tool to
17
17
  * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
18
18
  *
19
- * **No access check means privileged tools stay closed.** A caller that cannot say who is asking
20
- * gets only the tools that need no privilege.
19
+ * **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its
20
+ * door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;
21
+ * plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it
22
+ * reaches and the host resolves each one against the door's own declaration, so what passes here is
23
+ * what passes at the door.
24
+ *
25
+ * **All the doors, not one of them.** A tool that answers from two doors hands back what is behind
26
+ * both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).
27
+ *
28
+ * **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is
29
+ * asking gets only the tools that reach nothing.
21
30
  *
22
31
  * The permission check itself belongs to auth, which this package does not depend on, so the host
23
- * hands it in (`ai-assistant` builds it from the request state).
32
+ * hands it in (`ai-assistant` builds it from the request state and AUTH's field table).
24
33
  */
25
34
  const index_js_1 = require("../tool-registry/index.js");
26
35
  exports.EMPTY_TOOL_SCOPE = Object.freeze({ categories: [], specs: [] });
27
- const privilegeKey = (p) => [p.category, p.privilege, !!p.owner, !!p.super].join('|');
28
36
  async function resolveToolScope(requested, access) {
29
37
  if (!requested || requested.length === 0)
30
38
  return exports.EMPTY_TOOL_SCOPE;
31
- /* One check per distinct privilege — a category of ten read tools asks once. */
39
+ /* One check per distinct door — five tools behind `Query.jobOrders` ask once. */
32
40
  const answers = new Map();
33
- const mayUse = (spec) => {
34
- const privilege = spec.privilege;
35
- if (!privilege)
36
- return Promise.resolve(true);
41
+ const passes = (door) => {
42
+ if (!answers.has(door))
43
+ answers.set(door, access(door).catch(() => false));
44
+ return answers.get(door);
45
+ };
46
+ const mayUse = async (spec) => {
47
+ /*
48
+ * Said nothing. Not open — left out, and the boot report names it. An answer this tool built
49
+ * would be readable by no one (ADR-0073), so offering it would only produce dead answers.
50
+ */
51
+ if (!Array.isArray(spec.doors))
52
+ return false;
53
+ /* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */
54
+ if (spec.doors.length === 0)
55
+ return true;
37
56
  if (!access)
38
- return Promise.resolve(false);
39
- const key = privilegeKey(privilege);
40
- if (!answers.has(key))
41
- answers.set(key, access(privilege).catch(() => false));
42
- return answers.get(key);
57
+ return false;
58
+ const held = await Promise.all(spec.doors.map(passes));
59
+ return held.every(Boolean);
43
60
  };
44
61
  const categories = [];
45
62
  for (const category of (0, index_js_1.getToolCategories)()) {
@@ -1 +1 @@
1
- {"version":3,"file":"tool-scope.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-scope.ts"],"names":[],"mappings":";;;AA0CA,4CAsBC;AAMD,0CAMC;AAGD,8CAEC;AAjFD;;;;;;;;;;;;;;;;;GAiBG;AACH,wDAAsF;AAoBzE,QAAA,gBAAgB,GAAc,MAAM,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAA;AAEvF,MAAM,YAAY,GAAG,CAAC,CAAgB,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAE7F,KAAK,UAAU,gBAAgB,CAAC,SAA6B,EAAE,MAAwB;IAC5F,IAAI,CAAC,SAAS,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,wBAAgB,CAAA;IAEjE,gFAAgF;IAChF,MAAM,OAAO,GAAG,IAAI,GAAG,EAA4B,CAAA;IACnD,MAAM,MAAM,GAAG,CAAC,IAAc,EAAoB,EAAE;QAClD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAA;QAChC,IAAI,CAAC,SAAS;YAAE,OAAO,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;QAC5C,IAAI,CAAC,MAAM;YAAE,OAAO,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;QAC1C,MAAM,GAAG,GAAG,YAAY,CAAC,SAAS,CAAC,CAAA;QACnC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;QAC7E,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAE,CAAA;IAC1B,CAAC,CAAA;IAED,MAAM,UAAU,GAAqB,EAAE,CAAA;IACvC,KAAK,MAAM,QAAQ,IAAI,IAAA,4BAAiB,GAAE,EAAE,CAAC;QAC3C,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,SAAQ;QAChD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;QAC7D,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;QACzD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,CAAA;IACzH,CAAC;IACD,OAAO,cAAc,CAAC,UAAU,CAAC,CAAA;AACnC,CAAC;AAED;;;GAGG;AACH,SAAgB,eAAe,CAAC,KAAgB,EAAE,IAAiC;IACjF,OAAO,cAAc,CACnB,KAAK,CAAC,UAAU;SACb,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,QAAQ,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;SACtE,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CACjD,CAAA;AACH,CAAC;AAED,mEAAmE;AACnE,SAAgB,iBAAiB,CAAC,KAAgB;IAChD,OAAO,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;AAC5E,CAAC;AAED,SAAS,cAAc,CAAC,UAA4B;IAClD,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AAC9E,CAAC","sourcesContent":["/*\n * Which registered tools one conversation may use — decided once per request, before anything is\n * offered, so offering and dispatch read the same list.\n *\n * ── Two questions, one list ───────────────────────────────────────────────────────\n * Which categories did this surface ask for? `toolCategories`\n * Which of their tools may this caller use? each spec's `privilege`, asked of `access`\n *\n * **Nothing asked means nothing offered.** Omitting the category list used to mean \"every\n * registered category\"; that default is the one that opened board-import's Board-creating tool to\n * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).\n *\n * **No access check means privileged tools stay closed.** A caller that cannot say who is asking\n * gets only the tools that need no privilege.\n *\n * The permission check itself belongs to auth, which this package does not depend on, so the host\n * hands it in (`ai-assistant` builds it from the request state).\n */\nimport { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'\nimport type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js'\n\n/** Answers \"may this caller use a door guarded by this privilege\". */\nexport type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>\n\nexport interface ScopedCategory {\n readonly name: string\n readonly guidance: string\n /** The tools of this category this caller may use. Never empty — a category with none is left out. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport interface ToolScope {\n /** Categories with at least one usable tool, in registration order. */\n readonly categories: ReadonlyArray<ScopedCategory>\n /** Every usable tool, flat. Offering and dispatch both read this. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })\n\nconst privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')\n\nexport async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {\n if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE\n\n /* One check per distinct privilege — a category of ten read tools asks once. */\n const answers = new Map<string, Promise<boolean>>()\n const mayUse = (spec: ToolSpec): Promise<boolean> => {\n const privilege = spec.privilege\n if (!privilege) return Promise.resolve(true)\n if (!access) return Promise.resolve(false)\n const key = privilegeKey(privilege)\n if (!answers.has(key)) answers.set(key, access(privilege).catch(() => false))\n return answers.get(key)!\n }\n\n const categories: ScopedCategory[] = []\n for (const category of getToolCategories()) {\n if (!requested.includes(category.name)) continue\n const allowed = await Promise.all(category.specs.map(mayUse))\n const specs = category.specs.filter((_, i) => allowed[i])\n if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })\n }\n return fromCategories(categories)\n}\n\n/**\n * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category\n * left with nothing drops out, so its guidance is not read to a model that cannot call it.\n */\nexport function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {\n return fromCategories(\n scope.categories\n .map(category => ({ ...category, specs: category.specs.filter(keep) }))\n .filter(category => category.specs.length > 0)\n )\n}\n\n/** The rules of the categories in scope, for the system prompt. */\nexport function toolScopeGuidance(scope: ToolScope): string[] {\n return scope.categories.map(category => category.guidance).filter(Boolean)\n}\n\nfunction fromCategories(categories: ScopedCategory[]): ToolScope {\n return { categories, specs: categories.flatMap(category => category.specs) }\n}\n"]}
1
+ {"version":3,"file":"tool-scope.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-scope.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Which registered tools one conversation may use — decided once per request, before anything is\n * offered, so offering and dispatch read the same list.\n *\n * ── Two questions, one list ───────────────────────────────────────────────────────\n * Which categories did this surface ask for? `toolCategories`\n * Which of their tools may this caller use? each spec's `doors`, asked of `access`\n *\n * **Nothing asked means nothing offered.** Omitting the category list used to mean \"every\n * registered category\"; that default is the one that opened board-import's Board-creating tool to\n * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).\n *\n * **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its\n * door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;\n * plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it\n * reaches and the host resolves each one against the door's own declaration, so what passes here is\n * what passes at the door.\n *\n * **All the doors, not one of them.** A tool that answers from two doors hands back what is behind\n * both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).\n *\n * **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is\n * asking gets only the tools that reach nothing.\n *\n * The permission check itself belongs to auth, which this package does not depend on, so the host\n * hands it in (`ai-assistant` builds it from the request state and AUTH's field table).\n */\nimport { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'\nimport type { ToolSpec } from '../tool-registry/types.js'\n\n/** Answers \"may this caller pass this door\" — `'Query.jobOrders'`. */\nexport type ToolAccessCheck = (door: string) => Promise<boolean>\n\nexport interface ScopedCategory {\n readonly name: string\n readonly guidance: string\n /** The tools of this category this caller may use. Never empty — a category with none is left out. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport interface ToolScope {\n /** Categories with at least one usable tool, in registration order. */\n readonly categories: ReadonlyArray<ScopedCategory>\n /** Every usable tool, flat. Offering and dispatch both read this. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })\n\nexport async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {\n if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE\n\n /* One check per distinct door — five tools behind `Query.jobOrders` ask once. */\n const answers = new Map<string, Promise<boolean>>()\n const passes = (door: string): Promise<boolean> => {\n if (!answers.has(door)) answers.set(door, access!(door).catch(() => false))\n return answers.get(door)!\n }\n\n const mayUse = async (spec: ToolSpec): Promise<boolean> => {\n /*\n * Said nothing. Not open — left out, and the boot report names it. An answer this tool built\n * would be readable by no one (ADR-0073), so offering it would only produce dead answers.\n */\n if (!Array.isArray(spec.doors)) return false\n /* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */\n if (spec.doors.length === 0) return true\n if (!access) return false\n\n const held = await Promise.all(spec.doors.map(passes))\n return held.every(Boolean)\n }\n\n const categories: ScopedCategory[] = []\n for (const category of getToolCategories()) {\n if (!requested.includes(category.name)) continue\n const allowed = await Promise.all(category.specs.map(mayUse))\n const specs = category.specs.filter((_, i) => allowed[i])\n if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })\n }\n return fromCategories(categories)\n}\n\n/**\n * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category\n * left with nothing drops out, so its guidance is not read to a model that cannot call it.\n */\nexport function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {\n return fromCategories(\n scope.categories\n .map(category => ({ ...category, specs: category.specs.filter(keep) }))\n .filter(category => category.specs.length > 0)\n )\n}\n\n/** The rules of the categories in scope, for the system prompt. */\nexport function toolScopeGuidance(scope: ToolScope): string[] {\n return scope.categories.map(category => category.guidance).filter(Boolean)\n}\n\nfunction fromCategories(categories: ScopedCategory[]): ToolScope {\n return { categories, specs: categories.flatMap(category => category.specs) }\n}\n"]}
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, getCategoryGuidance, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry, getToolRegistryWarnings } from './registry';
7
7
  export type { ToolCategoryFilter, ToolRegistryWarning } from './registry';
8
- export type { ToolSpec, ToolSpecBase, ToolPrivilege, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext, ToolCallContext, ToolCallMode } from './types';
8
+ export type { ToolSpec, ToolSpecBase, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext, ToolCallContext, ToolCallMode } from './types';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAYmB;AAXjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA;AACjB,mHAAA,uBAAuB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry,\n getToolRegistryWarnings\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter, ToolRegistryWarning } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ToolPrivilege,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext,\n ToolCallContext,\n ToolCallMode\n} from './types'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAYmB;AAXjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA;AACjB,mHAAA,uBAAuB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry,\n getToolRegistryWarnings\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter, ToolRegistryWarning } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext,\n ToolCallContext,\n ToolCallMode\n} from './types'\n"]}
@@ -29,24 +29,25 @@ import type { ToolCategory, ToolSpec } from './types';
29
29
  /** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
30
30
  export interface ToolRegistryWarning {
31
31
  /**
32
- * `tool-privilege-undeclared` — nothing said which door this tool reaches.
33
- * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet
34
- * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
35
- * this is a different fact from saying nothing, and the report keeps the two apart.
32
+ * `tool-doors-undeclared` — nothing said which doors this tool reaches, so the gate leaves it out
33
+ * of every conversation (`resolveToolScope`) and this names it.
34
+ *
35
+ * What those doors require is **not** asked here. Registration runs while modules load, before the
36
+ * schema stands, so AUTH's field table is still empty; `ai-assistant`'s boot report asks the
37
+ * schema after it is built (`tool-door-not-in-schema` · `tool-door-has-no-privilege`).
36
38
  */
37
- code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared';
39
+ code: 'tool-doors-undeclared';
38
40
  params: {
39
41
  category: string;
40
42
  tool: string;
41
43
  kind: ToolSpec['kind'];
42
- doors?: string[];
43
44
  };
44
45
  /** For a person reading it; the code and params are what callers act on. */
45
46
  message: string;
46
47
  }
47
48
  /**
48
- * What registration accepted but the contract does not yet allow. Stage one of A1 records these
49
- * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
49
+ * What registered without saying which doors it reaches. A host prints this at boot; the gate has
50
+ * already left each of these tools out of every conversation.
50
51
  */
51
52
  export declare function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning>;
52
53
  /**
@@ -14,8 +14,8 @@ exports.clearToolRegistry = clearToolRegistry;
14
14
  const categories = new Map();
15
15
  const warnings = [];
16
16
  /**
17
- * What registration accepted but the contract does not yet allow. Stage one of A1 records these
18
- * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
17
+ * What registered without saying which doors it reaches. A host prints this at boot; the gate has
18
+ * already left each of these tools out of every conversation.
19
19
  */
20
20
  function getToolRegistryWarnings() {
21
21
  return warnings.slice();
@@ -44,19 +44,17 @@ function registerToolCategory(category) {
44
44
  'Write the rules for these tools in `guidance` — the side that registers them owns them.');
45
45
  }
46
46
  /*
47
- * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).
47
+ * A tool says which doors it reaches (ADR-0071 decision 2).
48
48
  *
49
- * The conversation door checks one privilege — may this person talk to the assistant. Without a
50
- * privilege on the tool, that single check opened every tool behind it: a user holding only
51
- * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a
52
- * Board.
49
+ * The conversation door checks one privilege — may this person talk to the assistant. On its own
50
+ * that single check opened every tool behind it: a user holding only `ai-assistant:mutation` could
51
+ * reach board-import's `materializeImportSession` and create a Board.
53
52
  *
54
- * **This is stage one of two: surface, then refuse** (ADR-0037, "먼저 드러내고, 목록이 비면 막는다").
55
- * Refusing now would stop every app whose tools predate the `privilege` field from booting, and
56
- * those apps compile against an installed copy that does not have the field yet. So an undeclared
57
- * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not
58
- * a log line — a warning only a log carries is not distinguishable from no warning. When the list
59
- * is empty across the apps, this becomes a refusal.
53
+ * Registration does not refuse a tool that says nothing; it records the omission as a code
54
+ * (`getToolRegistryWarnings`), not a log line — a warning only a log carries is not
55
+ * distinguishable from no warning. The refusal happens where it costs nothing to be late: the gate
56
+ * leaves an undeclared tool out of every conversation, so booting stays possible while the list is
57
+ * worked down, and no undeclared tool is reachable in the meantime.
60
58
  */
61
59
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name));
62
60
  /*
@@ -66,9 +64,8 @@ function registerToolCategory(category) {
66
64
  * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
67
65
  * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
68
66
  *
69
- * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,
70
- * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —
71
- * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
67
+ * 2026-09-18 부터 이 선언은 관문이기도 하다(ADR-0071 결정 2 보탬). 문을 안 적은 도구는 어느 대화에도
68
+ * 나가지 않으므로, 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
72
69
  */
73
70
  for (const spec of category.specs) {
74
71
  if (Array.isArray(spec.doors))
@@ -76,41 +73,9 @@ function registerToolCategory(category) {
76
73
  warnings.push({
77
74
  code: 'tool-doors-undeclared',
78
75
  params: { category: category.name, tool: spec.name, kind: spec.kind },
79
- message: `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +
80
- 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'
81
- });
82
- }
83
- for (const spec of category.specs) {
84
- const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege);
85
- if (declared)
86
- continue;
87
- /*
88
- * An empty list says this tool reaches no door — the ones that hand back what the surface sent
89
- * are that. Nothing to gate, nothing to report.
90
- */
91
- if (Array.isArray(spec.doors) && spec.doors.length === 0)
92
- continue;
93
- if (spec.doors?.length) {
94
- /*
95
- * The door is named and carries no privilege yet. The tool cannot be stricter than the door
96
- * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
97
- * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
98
- */
99
- warnings.push({
100
- code: 'tool-door-has-no-privilege',
101
- params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
102
- message: `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +
103
- 'whether those doors require anything is asked at boot, against the schema, not copied here ' +
104
- '(ADR-0072 decision 5)'
105
- });
106
- continue;
107
- }
108
- warnings.push({
109
- code: 'tool-privilege-undeclared',
110
- params: { category: category.name, tool: spec.name, kind: spec.kind },
111
- message: `tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +
112
- 'a tool that changes something says who may run it. What the doors it reaches require is a ' +
113
- "separate question, answered at boot against the schema (`tool-door-not-in-schema`)."
76
+ message: `tool '${spec.name}' in category '${category.name}' declares no doors, so it is offered to no ` +
77
+ 'conversation and answers built with it would be hidden from every reader including their ' +
78
+ 'author — declare `doors`, or `doors: []` when it reaches none'
114
79
  });
115
80
  }
116
81
  if (categories.has(category.name)) {
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":";;AAmDA,0DAEC;AAKD,oDA0HC;AAmBD,8CAEC;AAMD,0CAEC;AAMD,0CAOC;AAQD,kDAQC;AAKD,oCAOC;AAMD,kCAEC;AAKD,0BAEC;AAKD,4CAEC;AASD,8CAGC;AA/PD,MAAM,UAAU,GAA8B,IAAI,GAAG,EAAE,CAAA;AAgBvD,MAAM,QAAQ,GAA0B,EAAE,CAAA;AAE1C;;;GAGG;AACH,SAAgB,uBAAuB;IACrC,OAAO,QAAQ,CAAC,KAAK,EAAE,CAAA;AACzB,CAAC;AAED;;GAEG;AACH,SAAgB,oBAAoB,CAAC,QAAsB;IACzD;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAC5C,MAAM,IAAI,KAAK,CACb,4CAA4C,QAAQ,CAAC,IAAI,qBAAqB;YAC5E,sFAAsF;YACtF,wFAAwF;YACxF,yFAAyF,CAC5F,CAAA;IACH,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,KAAK,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAA;IAEjG;;;;;;;;;;OAUG;IACH,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;QAClC,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;YAAE,SAAQ;QAEvC,QAAQ,CAAC,IAAI,CAAC;YACZ,IAAI,EAAE,uBAAuB;YAC7B,MAAM,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;YACrE,OAAO,EACL,SAAS,IAAI,CAAC,IAAI,kBAAkB,QAAQ,CAAC,IAAI,gDAAgD;gBACjG,4GAA4G;SAC/G,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;QAClC,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,IAAI,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QAC/E,IAAI,QAAQ;YAAE,SAAQ;QAEtB;;;WAGG;QACH,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,SAAQ;QAElE,IAAI,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;YACvB;;;;eAIG;YACH,QAAQ,CAAC,IAAI,CAAC;gBACZ,IAAI,EAAE,4BAA4B;gBAClC,MAAM,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE;gBACxF,OAAO,EACL,SAAS,IAAI,CAAC,IAAI,aAAa,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,0CAA0C;oBAC/F,6FAA6F;oBAC7F,uBAAuB;aAC1B,CAAC,CAAA;YACF,SAAQ;QACV,CAAC;QAED,QAAQ,CAAC,IAAI,CAAC;YACZ,IAAI,EAAE,2BAA2B;YACjC,MAAM,EAAE,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;YACrE,OAAO,EACL,SAAS,IAAI,CAAC,IAAI,kBAAkB,QAAQ,CAAC,IAAI,uCAAuC;gBACxF,4FAA4F;gBAC5F,qFAAqF;SACxF,CAAC,CAAA;IACJ,CAAC;IAED,IAAI,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAClC,OAAO,CAAC,IAAI,CACV,sCAAsC,QAAQ,CAAC,IAAI,sCAAsC;YACvF,4FAA4F,CAC/F,CAAA;IACH,CAAC;IAED,4CAA4C;IAC5C,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACzD,KAAK,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,UAAU,EAAE,CAAC;QAC5C,IAAI,SAAS,KAAK,QAAQ,CAAC,IAAI;YAAE,SAAQ,CAAC,uBAAuB;QACjE,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAC/B,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC5B,MAAM,IAAI,KAAK,CACb,uCAAuC,IAAI,CAAC,IAAI,mCAAmC;oBACjF,IAAI,SAAS,0BAA0B,QAAQ,CAAC,IAAI,wCAAwC,CAC/F,CAAA;YACH,CAAC;QACH,CAAC;IACH,CAAC;IAED,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;AACzC,CAAC;AAWD,sCAAsC;AACtC,SAAS,OAAO,CAAC,IAAY,EAAE,MAA0B;IACvD,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;AACzC,CAAC;AAED;;GAEG;AACH,SAAgB,iBAAiB;IAC/B,OAAO,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC,CAAA;AACxC,CAAC;AAED;;;GAGG;AACH,SAAgB,eAAe;IAC7B,OAAO,eAAe,CAAC,SAAS,CAAC,CAAA;AACnC,CAAC;AAED;;;GAGG;AACH,SAAgB,eAAe,CAAC,MAA0B;IACxD,MAAM,GAAG,GAAe,EAAE,CAAA;IAC1B,KAAK,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,UAAU,EAAE,CAAC;QACxC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;YAAE,SAAQ;QACvC,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK;YAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC9C,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,SAAgB,mBAAmB,CAAC,MAA0B;IAC5D,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,KAAK,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,UAAU,EAAE,CAAC;QACxC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;YAAE,SAAQ;QACvC,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAA;QAC5C,IAAI,QAAQ;YAAE,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAClC,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;GAEG;AACH,SAAgB,YAAY,CAAC,IAAY,EAAE,MAA2B;IACpE,KAAK,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,UAAU,EAAE,CAAC;QACxC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;YAAE,SAAQ;QACvC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAA;QACjD,IAAI,IAAI;YAAE,OAAO,IAAI,CAAA;IACvB,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;GAGG;AACH,SAAgB,WAAW,CAAC,IAAY,EAAE,MAA2B;IACnE,OAAO,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,IAAI,CAAA;AACzC,CAAC;AAED;;GAEG;AACH,SAAgB,OAAO,CAAC,IAAY,EAAE,MAA2B;IAC/D,OAAO,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,SAAS,CAAA;AACjD,CAAC;AAED;;GAEG;AACH,SAAgB,gBAAgB,CAAC,YAAoB;IACnD,OAAO,UAAU,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,IAAI,EAAE,CAAA;AAClD,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,iBAAiB;IAC/B,UAAU,CAAC,KAAK,EAAE,CAAA;IAClB,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAA;AACrB,CAAC","sourcesContent":["/**\n * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.\n *\n * 사용 패턴 (외부 패키지의 server bootstrap):\n * ```ts\n * import { registerToolCategory } from '@things-factory/board-ai'\n *\n * registerToolCategory({\n * name: 'board-import',\n * specs: [\n * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },\n * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },\n * ...\n * ]\n * })\n * ```\n *\n * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이\n * 본 registry 에서 자동 도출.\n *\n * 정책:\n * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의\n * hot-reload / dev 환경 대응). console.warn 으로 알림.\n * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러\n * (LLM dispatch 가 모호해지지 않도록).\n * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.\n */\nimport type { ToolCategory, ToolSpec } from './types'\n\nconst categories: Map<string, ToolCategory> = new Map()\n\n/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */\nexport interface ToolRegistryWarning {\n /**\n * `tool-privilege-undeclared` — nothing said which door this tool reaches.\n * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet\n * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so\n * this is a different fact from saying nothing, and the report keeps the two apart.\n */\n code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared'\n params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }\n /** For a person reading it; the code and params are what callers act on. */\n message: string\n}\n\nconst warnings: ToolRegistryWarning[] = []\n\n/**\n * What registration accepted but the contract does not yet allow. Stage one of A1 records these\n * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.\n */\nexport function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {\n return warnings.slice()\n}\n\n/**\n * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.\n */\nexport function registerToolCategory(category: ToolCategory): void {\n /*\n * 지침 없는 카테고리는 **등록하지 않는다.**\n *\n * 도구 목록만 보낸 모델이 「불러야 할 때 부른다」를 맞히지 못한 사고가 있었다 — 조치 요청에\n * `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해, 사용자에게 없는 버튼을 누르라고\n * 안내했다. 화면에는 아무 일도 없었고 오류도 나지 않았다(§`ToolCategory.guidance`).\n *\n * 그 뒤로 규율을 적는 자리를 만들었지만 **선택이었다.** 선택인 규율은 그 사고를 아는 사람만\n * 채운다. 여기서 거절하는 이유가 그것이다 — 빠진 것을 부팅에서 만든 사람이 보고, 모델이 헛말을\n * 한 뒤에 사용자가 보지 않는다.\n *\n * 빈 문자열·공백도 거절한다. 통과시키면 이 검사가 형식이 되고, 형식이 된 검사는 없는 것과 같다.\n */\n if (!String(category.guidance ?? '').trim()) {\n throw new Error(\n `[ai-client-base/tool-registry] category '${category.name}' has no guidance. ` +\n 'Tool descriptions say what a tool does; guidance says when the model must call one. ' +\n 'Without it a model answers as though it called a tool it did not, and nothing errors. ' +\n 'Write the rules for these tools in `guidance` — the side that registers them owns them.'\n )\n }\n\n /*\n * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).\n *\n * The conversation door checks one privilege — may this person talk to the assistant. Without a\n * privilege on the tool, that single check opened every tool behind it: a user holding only\n * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a\n * Board.\n *\n * **This is stage one of two: surface, then refuse** (ADR-0037, \"먼저 드러내고, 목록이 비면 막는다\").\n * Refusing now would stop every app whose tools predate the `privilege` field from booting, and\n * those apps compile against an installed copy that does not have the field yet. So an undeclared\n * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not\n * a log line — a warning only a log carries is not distinguishable from no warning. When the list\n * is empty across the apps, this becomes a refusal.\n */\n warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))\n\n /*\n * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────\n *\n * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는\n * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면\n * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.\n *\n * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,\n * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —\n * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.\n */\n for (const spec of category.specs) {\n if (Array.isArray(spec.doors)) continue\n\n warnings.push({\n code: 'tool-doors-undeclared',\n params: { category: category.name, tool: spec.name, kind: spec.kind },\n message:\n `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +\n 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'\n })\n }\n\n for (const spec of category.specs) {\n const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)\n if (declared) continue\n\n /*\n * An empty list says this tool reaches no door — the ones that hand back what the surface sent\n * are that. Nothing to gate, nothing to report.\n */\n if (Array.isArray(spec.doors) && spec.doors.length === 0) continue\n\n if (spec.doors?.length) {\n /*\n * The door is named and carries no privilege yet. The tool cannot be stricter than the door\n * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn\n * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.\n */\n warnings.push({\n code: 'tool-door-has-no-privilege',\n params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },\n message:\n `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +\n 'whether those doors require anything is asked at boot, against the schema, not copied here ' +\n '(ADR-0072 decision 5)'\n })\n continue\n }\n\n warnings.push({\n code: 'tool-privilege-undeclared',\n params: { category: category.name, tool: spec.name, kind: spec.kind },\n message:\n `tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +\n 'a tool that changes something says who may run it. What the doors it reaches require is a ' +\n \"separate question, answered at boot against the schema (`tool-door-not-in-schema`).\"\n })\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n '(normal on a dev hot reload; in production it means the same category is registered twice)'\n )\n }\n\n // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부\n const newNames = new Set(category.specs.map(s => s.name))\n for (const [otherName, other] of categories) {\n if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK\n for (const spec of other.specs) {\n if (newNames.has(spec.name)) {\n throw new Error(\n `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +\n `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`\n )\n }\n }\n }\n\n categories.set(category.name, category)\n}\n\n/**\n * 대화별 도구 노출 제한 — 허용 카테고리 목록.\n *\n * `undefined` 는 **제한 없음**(등록된 전부). 호스트가 목록을 주면 그 카테고리에 속한 spec 만\n * 정의·실행 대상이 된다. 정의를 숨기는 것만으로는 부족하다 — 모델이 이름을 추측하거나 옛 대화가\n * 재생되면 숨긴 도구도 호출되므로 **dispatch 조회(findToolSpec)에도 같은 목록을 넘겨야** 한다.\n */\nexport type ToolCategoryFilter = ReadonlyArray<string> | undefined\n\n/** 허용 목록에 든 카테고리인가. 목록 없음 = 전부 허용. */\nfunction allowed(name: string, filter: ToolCategoryFilter): boolean {\n return !filter || filter.includes(name)\n}\n\n/**\n * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.\n */\nexport function getToolCategories(): ReadonlyArray<ToolCategory> {\n return Array.from(categories.values())\n}\n\n/**\n * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가\n * LLM tool 정의 합집합으로 사용.\n */\nexport function getAllToolSpecs(): ReadonlyArray<ToolSpec> {\n return getToolSpecsFor(undefined)\n}\n\n/**\n * 허용 카테고리의 spec 평면 view. `undefined` = 전부(= getAllToolSpecs 와 동일).\n * LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.\n */\nexport function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec> {\n const out: ToolSpec[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n for (const spec of cat.specs) out.push(spec)\n }\n return out\n}\n\n/**\n * 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.\n *\n * 도구 목록만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시 불러야 하는지\" 는 모른다.\n * 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).\n */\nexport function getCategoryGuidance(filter: ToolCategoryFilter): string[] {\n const out: string[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const guidance = (cat.guidance ?? '').trim()\n if (guidance) out.push(guidance)\n }\n return out\n}\n\n/**\n * tool name 으로 spec 검색. 없으면 undefined.\n */\nexport function findToolSpec(name: string, filter?: ToolCategoryFilter): ToolSpec | undefined {\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const spec = cat.specs.find(s => s.name === name)\n if (spec) return spec\n }\n return undefined\n}\n\n/**\n * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.\n * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.\n */\nexport function getToolKind(name: string, filter?: ToolCategoryFilter): ToolSpec['kind'] | undefined {\n return findToolSpec(name, filter)?.kind\n}\n\n/**\n * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.\n */\nexport function hasTool(name: string, filter?: ToolCategoryFilter): boolean {\n return findToolSpec(name, filter) !== undefined\n}\n\n/**\n * 카테고리 이름으로 spec 만 추출.\n */\nexport function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {\n return categories.get(categoryName)?.specs ?? []\n}\n\n/**\n * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.\n *\n * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.\n * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후\n * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.\n */\nexport function clearToolRegistry(): void {\n categories.clear()\n warnings.length = 0\n}\n"]}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.\n *\n * 사용 패턴 (외부 패키지의 server bootstrap):\n * ```ts\n * import { registerToolCategory } from '@things-factory/board-ai'\n *\n * registerToolCategory({\n * name: 'board-import',\n * specs: [\n * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },\n * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },\n * ...\n * ]\n * })\n * ```\n *\n * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이\n * 본 registry 에서 자동 도출.\n *\n * 정책:\n * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의\n * hot-reload / dev 환경 대응). console.warn 으로 알림.\n * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러\n * (LLM dispatch 가 모호해지지 않도록).\n * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.\n */\nimport type { ToolCategory, ToolSpec } from './types'\n\nconst categories: Map<string, ToolCategory> = new Map()\n\n/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */\nexport interface ToolRegistryWarning {\n /**\n * `tool-doors-undeclared` — nothing said which doors this tool reaches, so the gate leaves it out\n * of every conversation (`resolveToolScope`) and this names it.\n *\n * What those doors require is **not** asked here. Registration runs while modules load, before the\n * schema stands, so AUTH's field table is still empty; `ai-assistant`'s boot report asks the\n * schema after it is built (`tool-door-not-in-schema` · `tool-door-has-no-privilege`).\n */\n code: 'tool-doors-undeclared'\n params: { category: string; tool: string; kind: ToolSpec['kind'] }\n /** For a person reading it; the code and params are what callers act on. */\n message: string\n}\n\nconst warnings: ToolRegistryWarning[] = []\n\n/**\n * What registered without saying which doors it reaches. A host prints this at boot; the gate has\n * already left each of these tools out of every conversation.\n */\nexport function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {\n return warnings.slice()\n}\n\n/**\n * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.\n */\nexport function registerToolCategory(category: ToolCategory): void {\n /*\n * 지침 없는 카테고리는 **등록하지 않는다.**\n *\n * 도구 목록만 보낸 모델이 「불러야 할 때 부른다」를 맞히지 못한 사고가 있었다 — 조치 요청에\n * `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해, 사용자에게 없는 버튼을 누르라고\n * 안내했다. 화면에는 아무 일도 없었고 오류도 나지 않았다(§`ToolCategory.guidance`).\n *\n * 그 뒤로 규율을 적는 자리를 만들었지만 **선택이었다.** 선택인 규율은 그 사고를 아는 사람만\n * 채운다. 여기서 거절하는 이유가 그것이다 — 빠진 것을 부팅에서 만든 사람이 보고, 모델이 헛말을\n * 한 뒤에 사용자가 보지 않는다.\n *\n * 빈 문자열·공백도 거절한다. 통과시키면 이 검사가 형식이 되고, 형식이 된 검사는 없는 것과 같다.\n */\n if (!String(category.guidance ?? '').trim()) {\n throw new Error(\n `[ai-client-base/tool-registry] category '${category.name}' has no guidance. ` +\n 'Tool descriptions say what a tool does; guidance says when the model must call one. ' +\n 'Without it a model answers as though it called a tool it did not, and nothing errors. ' +\n 'Write the rules for these tools in `guidance` — the side that registers them owns them.'\n )\n }\n\n /*\n * A tool says which doors it reaches (ADR-0071 decision 2).\n *\n * The conversation door checks one privilege — may this person talk to the assistant. On its own\n * that single check opened every tool behind it: a user holding only `ai-assistant:mutation` could\n * reach board-import's `materializeImportSession` and create a Board.\n *\n * Registration does not refuse a tool that says nothing; it records the omission as a code\n * (`getToolRegistryWarnings`), not a log line — a warning only a log carries is not\n * distinguishable from no warning. The refusal happens where it costs nothing to be late: the gate\n * leaves an undeclared tool out of every conversation, so booting stays possible while the list is\n * worked down, and no undeclared tool is reachable in the meantime.\n */\n warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))\n\n /*\n * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────\n *\n * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는\n * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면\n * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.\n *\n * 2026-09-18 부터 이 선언은 관문이기도 하다(ADR-0071 결정 2 보탬). 문을 안 적은 도구는 어느 대화에도\n * 나가지 않으므로, 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.\n */\n for (const spec of category.specs) {\n if (Array.isArray(spec.doors)) continue\n\n warnings.push({\n code: 'tool-doors-undeclared',\n params: { category: category.name, tool: spec.name, kind: spec.kind },\n message:\n `tool '${spec.name}' in category '${category.name}' declares no doors, so it is offered to no ` +\n 'conversation and answers built with it would be hidden from every reader including their ' +\n 'author — declare `doors`, or `doors: []` when it reaches none'\n })\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n '(normal on a dev hot reload; in production it means the same category is registered twice)'\n )\n }\n\n // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부\n const newNames = new Set(category.specs.map(s => s.name))\n for (const [otherName, other] of categories) {\n if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK\n for (const spec of other.specs) {\n if (newNames.has(spec.name)) {\n throw new Error(\n `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +\n `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`\n )\n }\n }\n }\n\n categories.set(category.name, category)\n}\n\n/**\n * 대화별 도구 노출 제한 — 허용 카테고리 목록.\n *\n * `undefined` 는 **제한 없음**(등록된 전부). 호스트가 목록을 주면 그 카테고리에 속한 spec 만\n * 정의·실행 대상이 된다. 정의를 숨기는 것만으로는 부족하다 — 모델이 이름을 추측하거나 옛 대화가\n * 재생되면 숨긴 도구도 호출되므로 **dispatch 조회(findToolSpec)에도 같은 목록을 넘겨야** 한다.\n */\nexport type ToolCategoryFilter = ReadonlyArray<string> | undefined\n\n/** 허용 목록에 든 카테고리인가. 목록 없음 = 전부 허용. */\nfunction allowed(name: string, filter: ToolCategoryFilter): boolean {\n return !filter || filter.includes(name)\n}\n\n/**\n * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.\n */\nexport function getToolCategories(): ReadonlyArray<ToolCategory> {\n return Array.from(categories.values())\n}\n\n/**\n * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가\n * LLM tool 정의 합집합으로 사용.\n */\nexport function getAllToolSpecs(): ReadonlyArray<ToolSpec> {\n return getToolSpecsFor(undefined)\n}\n\n/**\n * 허용 카테고리의 spec 평면 view. `undefined` = 전부(= getAllToolSpecs 와 동일).\n * LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.\n */\nexport function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec> {\n const out: ToolSpec[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n for (const spec of cat.specs) out.push(spec)\n }\n return out\n}\n\n/**\n * 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.\n *\n * 도구 목록만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시 불러야 하는지\" 는 모른다.\n * 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).\n */\nexport function getCategoryGuidance(filter: ToolCategoryFilter): string[] {\n const out: string[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const guidance = (cat.guidance ?? '').trim()\n if (guidance) out.push(guidance)\n }\n return out\n}\n\n/**\n * tool name 으로 spec 검색. 없으면 undefined.\n */\nexport function findToolSpec(name: string, filter?: ToolCategoryFilter): ToolSpec | undefined {\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const spec = cat.specs.find(s => s.name === name)\n if (spec) return spec\n }\n return undefined\n}\n\n/**\n * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.\n * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.\n */\nexport function getToolKind(name: string, filter?: ToolCategoryFilter): ToolSpec['kind'] | undefined {\n return findToolSpec(name, filter)?.kind\n}\n\n/**\n * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.\n */\nexport function hasTool(name: string, filter?: ToolCategoryFilter): boolean {\n return findToolSpec(name, filter) !== undefined\n}\n\n/**\n * 카테고리 이름으로 spec 만 추출.\n */\nexport function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {\n return categories.get(categoryName)?.specs ?? []\n}\n\n/**\n * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.\n *\n * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.\n * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후\n * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.\n */\nexport function clearToolRegistry(): void {\n categories.clear()\n warnings.length = 0\n}\n"]}
@@ -95,22 +95,6 @@ export interface ExternalToolContext {
95
95
  state?: any;
96
96
  [k: string]: any;
97
97
  }
98
- /**
99
- * The privilege a tool requires — the same `category`/`privilege` the tool's GraphQL door declares
100
- * (`@privilege(category, privilege, owner?, super?)`), so the fields mirror `PrivilegeObject`.
101
- *
102
- * A tool is not a new door; it is another way into a door that already exists. A tool that creates
103
- * a Board goes through the privilege that creating a Board already needs. Minting an AI-only
104
- * privilege name would put two doors on one effect, and one of them would end up looser.
105
- */
106
- export interface ToolPrivilege {
107
- category: string;
108
- privilege: string;
109
- /** Domain owners pass without the privilege — mirror the door's `domainOwnerGranted`. */
110
- owner?: boolean;
111
- /** Super users pass without the privilege — mirror the door's `superUserGranted`. */
112
- super?: boolean;
113
- }
114
98
  /**
115
99
  * 모든 ToolSpec 공통 메타데이터.
116
100
  */
@@ -126,13 +110,6 @@ export interface ToolSpecBase {
126
110
  themeNote?: string;
127
111
  /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
128
112
  schema: any;
129
- /**
130
- * The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
131
- *
132
- * Expected for `write`, `action` and `external` — registration records a
133
- * `tool-privilege-undeclared` warning for one without it (A1 stage one).
134
- */
135
- privilege?: ToolPrivilege;
136
113
  /**
137
114
  * Which GraphQL doors this tool reaches — `['Query.twinNow']`,
138
115
  * `['Query.productionSummary', 'Query.pendingSummary']`.
@@ -147,15 +124,23 @@ export interface ToolSpecBase {
147
124
  * plant's factory overview answers from two, and asking for only one of them would hand back
148
125
  * half of what the caller may not see.
149
126
  *
150
- * Three states, and the boot report keeps them apart:
151
- * - doors and a privilege — the gate checks it
152
- * - `doors: []` — this tool reaches no door at all. The ones that hand back what the surface
153
- * sent (`getOpenScene`, `getStationScreen`) are this: there is nothing behind them to gate.
154
- * - doors with no privilege — the doors themselves declare none yet (twin's queries, waiting
155
- * on AUTH stage two). A tool cannot be stricter than the door it reaches.
127
+ * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its
128
+ * own copy of what its door asks; the copy went stale the day a door changed its declaration, and
129
+ * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field
130
+ * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same
131
+ * answer, one declaration.
132
+ *
133
+ * Three states, and they are not the same thing:
134
+ * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.
135
+ * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface
136
+ * sent (`getOpenScene`) are this: there is nothing behind them to gate.
137
+ * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every
138
+ * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it
139
+ * would be hidden from every reader including its author (ADR-0073), so opening it would
140
+ * produce a tool whose answers no one can read.
156
141
  *
157
- * The privilege is **the door's**, copied, and a test in each app compares the copy with the
158
- * directive on that field. When the schema can be read at boot, the copy goes away.
142
+ * A door that declares nothing of its own opens as far as that door opens — a tool is never
143
+ * stricter than the door it reaches, and never softer.
159
144
  */
160
145
  doors?: string[];
161
146
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * The privilege a tool requires — the same `category`/`privilege` the tool's GraphQL door declares\n * (`@privilege(category, privilege, owner?, super?)`), so the fields mirror `PrivilegeObject`.\n *\n * A tool is not a new door; it is another way into a door that already exists. A tool that creates\n * a Board goes through the privilege that creating a Board already needs. Minting an AI-only\n * privilege name would put two doors on one effect, and one of them would end up looser.\n */\nexport interface ToolPrivilege {\n category: string\n privilege: string\n /** Domain owners pass without the privilege — mirror the door's `domainOwnerGranted`. */\n owner?: boolean\n /** Super users pass without the privilege — mirror the door's `superUserGranted`. */\n super?: boolean\n}\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.\n *\n * Expected for `write`, `action` and `external` — registration records a\n * `tool-privilege-undeclared` warning for one without it (A1 stage one).\n */\n privilege?: ToolPrivilege\n\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * Three states, and the boot report keeps them apart:\n * - doors and a privilege — the gate checks it\n * - `doors: []` — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`, `getStationScreen`) are this: there is nothing behind them to gate.\n * - doors with no privilege — the doors themselves declare none yet (twin's queries, waiting\n * on AUTH stage two). A tool cannot be stricter than the door it reaches.\n *\n * The privilege is **the door's**, copied, and a test in each app compares the copy with the\n * directive on that field. When the schema can be read at boot, the copy goes away.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its\n * own copy of what its door asks; the copy went stale the day a door changed its declaration, and\n * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field\n * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same\n * answer, one declaration.\n *\n * Three states, and they are not the same thing:\n * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.\n * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`) are this: there is nothing behind them to gate.\n * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every\n * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it\n * would be hidden from every reader including its author (ADR-0073), so opening it would\n * produce a tool whose answers no one can read.\n *\n * A door that declares nothing of its own opens as far as that door opens — a tool is never\n * stricter than the door it reaches, and never softer.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}