@fcg-labs/cx-agent-hook 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cx-agent-hook CLI — 에이전트 스킬 설치 (의존성 0).
4
+ *
5
+ * 개발자가 자기 코딩 에이전트에게 "cx-agent-hook 셋업 도와줘" 라고만 해도 되게, 패키지에
6
+ * 실린 스킬(skills/cx-agent-hook-setup/SKILL.md + reference.md)을 프로젝트의 에이전트
7
+ * 디렉터리에 복사한다. 네트워크 0 — 설치된 패키지 안의 파일만 쓴다.
8
+ *
9
+ * npx cx-agent-hook skills install # → .claude/skills/cx-agent-hook-setup/ (Claude Code)
10
+ * npx cx-agent-hook skills install --cursor # + .cursor/rules/cx-agent-hook-setup.mdc (Cursor 규칙 → 스킬 참조)
11
+ * npx cx-agent-hook skills install --agents-md# + AGENTS.md 에 한 줄 추가 (Codex 등 AGENTS.md 계열)
12
+ * npx cx-agent-hook skills install --all # 위 전부
13
+ * npx cx-agent-hook skills install --to DIR # 임의 디렉터리 (예: .agents/skills)
14
+ * npx cx-agent-hook skills path # SKILL.md 절대 경로 (에이전트에게 "이 파일 읽어" 라고 줄 때)
15
+ */
16
+ import { cpSync, existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const pkgRoot = resolve(here, "..");
22
+ const SKILL = "cx-agent-hook-setup";
23
+ const src = join(pkgRoot, "skills", SKILL);
24
+ const [cmd, sub, ...rest] = process.argv.slice(2);
25
+
26
+ const flag = (name) => rest.includes(name);
27
+ const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
28
+ const cwd = process.cwd();
29
+ const log = (s) => process.stdout.write(s + "\n");
30
+
31
+ function usage(code = 0) {
32
+ log(`cx-agent-hook skills <install|path> [--to DIR] [--cursor] [--agents-md] [--all]
33
+ install 스킬을 .claude/skills/${SKILL}/ 에 복사 (Claude Code). --cursor / --agents-md / --all 로 다른 에이전트도.
34
+ path SKILL.md 절대 경로 출력 — 에이전트에게 "이 파일을 읽고 따라줘" 라고 줄 때.
35
+ 그 뒤 에이전트에게: "cx-agent-hook 셋업 도와줘" (adminApi·처리 카드까지가 셋업이다)`);
36
+ process.exit(code);
37
+ }
38
+
39
+ if (cmd !== "skills" || !sub) usage(cmd ? 1 : 0);
40
+ if (!existsSync(join(src, "SKILL.md"))) { log(`스킬 파일이 없습니다: ${src}`); process.exit(1); }
41
+
42
+ if (sub === "path") { log(join(src, "SKILL.md")); process.exit(0); }
43
+ if (sub !== "install") usage(1);
44
+
45
+ const done = [];
46
+ const to = opt("--to");
47
+ const targets = to ? [resolve(cwd, to, SKILL)] : [join(cwd, ".claude", "skills", SKILL)];
48
+ if (flag("--all") && !to) { /* Claude Code 는 기본 대상 */ }
49
+ for (const dir of targets) {
50
+ mkdirSync(dir, { recursive: true });
51
+ cpSync(src, dir, { recursive: true });
52
+ done.push(`스킬 복사 → ${dir}`);
53
+ }
54
+ const skillRel = to ? join(to, SKILL, "SKILL.md") : join(".claude", "skills", SKILL, "SKILL.md");
55
+
56
+ if (flag("--cursor") || flag("--all")) {
57
+ const rulesDir = join(cwd, ".cursor", "rules");
58
+ mkdirSync(rulesDir, { recursive: true });
59
+ const mdc = join(rulesDir, `${SKILL}.mdc`);
60
+ writeFileSync(mdc, `---
61
+ description: cx-agent-hook (@fcg-labs/cx-agent-hook) 셋업·배선·업그레이드 — AI 답변 초안 패널 + 처리 카드(adminApi)
62
+ globs:
63
+ alwaysApply: false
64
+ ---
65
+ cx-agent-hook 관련 작업(셋업·연동·처리 카드·adminApi·업그레이드)은 먼저 \`${skillRel}\` 을 읽고
66
+ 그 절차(실측 → 결정 규칙 → 6단계 → 완료 체크리스트)를 그대로 따른다. reference.md 에 없는 API 이름을 쓰지 않는다.
67
+ `);
68
+ done.push(`Cursor 규칙 → ${mdc}`);
69
+ }
70
+ if (flag("--agents-md") || flag("--all")) {
71
+ const agentsMd = join(cwd, "AGENTS.md");
72
+ const line = `\n## cx-agent-hook\ncx-agent-hook(@fcg-labs/cx-agent-hook) 셋업·연동·처리 카드(adminApi)·업그레이드 작업은 먼저 \`${skillRel}\` 을 읽고 그 절차와 완료 체크리스트를 따른다.\n`;
73
+ const cur = existsSync(agentsMd) ? readFileSync(agentsMd, "utf8") : "";
74
+ if (cur.includes("cx-agent-hook-setup")) done.push(`AGENTS.md 는 이미 스킬을 가리킴 (변경 없음)`);
75
+ else { appendFileSync(agentsMd, (cur && !cur.endsWith("\n") ? "\n" : "") + line); done.push(`AGENTS.md 에 안내 한 줄 추가 → ${agentsMd}`); }
76
+ }
77
+ for (const d of done) log(d);
78
+ log(`\n다음: 에이전트에게 "cx-agent-hook 셋업 도와줘" — 답변 패널과 처리 카드(adminApi)까지가 셋업입니다.`);
package/client.js CHANGED
@@ -37,8 +37,10 @@ const PATHS = {
37
37
  ingress: (d) => `/v1/domains/${d}/ingress`,
38
38
  // 조사 접점 (E-8) — 트리거는 큐 적재만, 상태는 경량 메타만 (허브 계약)
39
39
  investigation: (d) => `/v1/domains/${d}/investigations`,
40
- // 처리 액션 (actions/1) — 실행 요청·상태·재조정. 정본: docs/contracts/action-contract-v1.md
40
+ // 처리 액션 (actions/1 프로파일 B) — 선점·결과 보고·상태·잠금·후보 발행.
41
+ // 정본: docs/contracts/action-contract-v1.md
41
42
  actions: (d) => `/v1/domains/${d}/actions`,
43
+ actionCandidates: (d) => `/v1/domains/${d}/action-candidates`,
42
44
  },
43
45
  // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
44
46
  };
@@ -594,29 +596,28 @@ export class CxAgentClient {
594
596
  }
595
597
  }
596
598
 
597
- // ── 처리 액션 (actions/1) ──────────────────────────────────────────────
599
+ // ── 처리 액션 (actions/1 프로파일 B — 브라우저 실행기) ────────────────────
598
600
  //
599
- // **재시도 0.** sendFeedback 5xx 재시도 루프를 여기 쓰면 비멱등 실행이
600
- // 간다. 네트워크 오류면 호출자가 actionStatus(request_id) 원장을 묻고,
601
- // 404(미도달 확정)일 때만 같은 request_id 1회 재전송한다 (client 밖 규칙).
601
+ // 흐름: requestAction 으로 허브에 선점(received) 호출자가 CMS 자기 API
602
+ // 부른다 reportActionResultsucceeded/failed/unknown 보고. 허브는 어떤
603
+ // 고객사 서버도 부르지 않는다. **재시도 0.** 선점은 부작용이 없어 미도달(404)
604
+ // 확정 시 1회 재전송이 안전하지만(session 규칙), CMS 호출·보고에는 재시도가 없다.
602
605
 
603
606
  /**
604
- * 실행 요청. request_id 는 호출자(세션)가 발급·영속한 뒤 넘긴다 — 멱등 키.
607
+ * 선점. request_id 는 호출자(세션)가 발급·영속한 뒤 넘긴다 — 멱등 키.
605
608
  * @returns {Promise<{ok:boolean, state:string, requestId:string, reasonCode:string,
606
- * message:string, actorAttested:string, auditRef:string, result:object|null,
607
- * deduplicated:boolean, error:string|null, httpStatus:number}>}
609
+ * message:string, targetKey:string, deduplicated:boolean, error:string|null, httpStatus:number}>}
608
610
  */
609
- async requestAction({ requestId, offer, actorAssertion, actorClaimed, inquiryRef } = {}) {
611
+ async requestAction({ requestId, offer, actorClaimed, inquiryRef } = {}) {
610
612
  if (this.api !== "hub" || !requestId || !offer || !offer.offer_id) {
611
- return { ok: false, state: "", requestId: requestId || "", reasonCode: "",
612
- message: "", actorAttested: "", auditRef: "", result: null,
613
- deduplicated: false, error: "unsupported", httpStatus: 0 };
613
+ return { ok: false, state: "", requestId: requestId || "", reasonCode: "", message: "",
614
+ targetKey: "", deduplicated: false, error: "unsupported", httpStatus: 0 };
614
615
  }
615
616
  const body = {
616
617
  request_id: String(requestId), offer_id: String(offer.offer_id),
617
618
  event_id: String(offer.event_id || ""), action_key: String(offer.action_key || ""),
618
619
  params_hash: String(offer.params_hash || ""), params: offer.params_bound || {},
619
- actor_assertion: String(actorAssertion || ""), actor_claimed: String(actorClaimed || ""),
620
+ target_key: String(offer.target_key || ""), actor_claimed: String(actorClaimed || ""),
620
621
  inquiry_ref: inquiryRef != null ? String(inquiryRef) : "",
621
622
  confirmed_at: new Date().toISOString(),
622
623
  };
@@ -625,20 +626,47 @@ export class CxAgentClient {
625
626
  const view = (d) => ({
626
627
  state: d.state || "", requestId: d.request_id || String(requestId),
627
628
  reasonCode: d.reason_code || "", message: d.message || "",
628
- actorAttested: d.actor_attested || "", auditRef: d.audit_ref || "",
629
- result: d.result ?? null, deduplicated: Boolean(d.deduplicated),
629
+ targetKey: d.target_key || "", deduplicated: Boolean(d.deduplicated),
630
630
  });
631
631
  if (status === 200 || status === 202) {
632
632
  return { ok: true, ...view(data), error: null, httpStatus: status };
633
633
  }
634
- // 409 offer_locked / request_conflict, 422 계약 위반, 503 잠김 — 그대로 전달
634
+ // 409 offer_locked / request_conflict, 422 계약 위반 — 그대로 전달
635
635
  this.onError(new Error(`HTTP ${status}`), { op: "requestAction", code: data.error || "http_error" });
636
636
  return { ok: false, ...view(data), error: data.error || `http_${status}`, httpStatus: status };
637
637
  } catch (err) {
638
638
  this.onError(err, { op: "requestAction", code: "network_error" });
639
639
  return { ok: false, state: "", requestId: String(requestId), reasonCode: "", message: "",
640
- actorAttested: "", auditRef: "", result: null, deduplicated: false,
641
- error: "network_error", httpStatus: 0 };
640
+ targetKey: "", deduplicated: false, error: "network_error", httpStatus: 0 };
641
+ }
642
+ }
643
+
644
+ /**
645
+ * 결과 보고 — CMS 호출이 끝난 뒤 1회. 같은 종단 재보고는 허브가 멱등(200 deduplicated),
646
+ * 다른 종단은 409 state_conflict, 선점 없는 request 는 404.
647
+ * @param {string} requestId
648
+ * @param {{state:"succeeded"|"failed"|"unknown", reasonCode?:string, message?:string,
649
+ * httpStatus?:number, latencyMs?:number, result?:object}} r
650
+ */
651
+ async reportActionResult(requestId, { state, reasonCode, message, httpStatus, latencyMs, result } = {}) {
652
+ if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported", httpStatus: 0 };
653
+ const body = {
654
+ state: String(state || ""), reason_code: String(reasonCode || ""), message: String(message || "").slice(0, 200),
655
+ http_status: Number(httpStatus) || 0, latency_ms: Number(latencyMs) || 0, result: result || null,
656
+ };
657
+ try {
658
+ const { status, data } = await this._post(
659
+ PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}/result`, body,
660
+ );
661
+ if (status === 200) {
662
+ return { ok: true, state: data.state || "", reasonCode: data.reason_code || "", message: data.message || "",
663
+ deduplicated: Boolean(data.deduplicated), error: null, httpStatus: status };
664
+ }
665
+ this.onError(new Error(`HTTP ${status}`), { op: "reportActionResult", code: data.error || "http_error" });
666
+ return { ok: false, state: data.state || "", error: data.error || `http_${status}`, httpStatus: status };
667
+ } catch (err) {
668
+ this.onError(err, { op: "reportActionResult", code: "network_error" });
669
+ return { ok: false, state: "", error: "network_error", httpStatus: 0 };
642
670
  }
643
671
  }
644
672
 
@@ -657,22 +685,41 @@ export class CxAgentClient {
657
685
  );
658
686
  }
659
687
 
660
- /** 재조정 — lookup 전용 (재실행 아님). unknown 만 의미 있고 종단이면 그대로 온다. */
661
- async actionReconcile(requestId) {
662
- if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
688
+ /**
689
+ * 잠금 조회 — (action_key, target_key) 최근 성공. locked 면 params_hash·at·actor_claimed.
690
+ * 카드 잠금 판정은 호출자: locked && params_hash === offer.params_hash.
691
+ */
692
+ async actionLock(actionKey, targetKey) {
693
+ if (this.api !== "hub" || !actionKey || !targetKey) return { ok: false, locked: false, error: "unsupported" };
694
+ const q = `?action_key=${encodeURIComponent(actionKey)}&target_key=${encodeURIComponent(targetKey)}`;
695
+ return this._getJson(PATHS.hub.actions(this.domain) + "/lock" + q, "actionLock", (status, data) => {
696
+ if (status === 200) {
697
+ return { ok: true, locked: Boolean(data.locked), paramsHash: data.params_hash || "",
698
+ requestId: data.request_id || "", at: data.at || "", actorClaimed: data.actor_claimed || "", error: null };
699
+ }
700
+ return { ok: false, locked: false, error: data.error || `http_${status}` };
701
+ });
702
+ }
703
+
704
+ /**
705
+ * 후보 스냅샷 발행 — "이 CMS 가 이미 하는 처리" (키·메서드·경로 템플릿, 호스트 없음).
706
+ * 같은 snapshot_hash 면 허브가 200 unchanged 로 쓰기 0. 신규/교체는 201.
707
+ * @param {{snapshotHash:string, items:Array<{endpoint_key:string, methods:string[], path_template:string}>}} snap
708
+ */
709
+ async publishActionCandidates({ snapshotHash, items } = {}) {
710
+ if (this.api !== "hub" || !Array.isArray(items)) return { ok: false, error: "unsupported", httpStatus: 0 };
663
711
  try {
664
- const { status, data } = await this._post(
665
- PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}/reconcile`, {},
666
- );
667
- if (status === 200 || status === 202) {
668
- return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
669
- message: data.message || "", actorAttested: data.actor_attested || "",
670
- auditRef: data.audit_ref || "", error: null };
712
+ const { status, data } = await this._post(PATHS.hub.actionCandidates(this.domain),
713
+ { snapshot_hash: String(snapshotHash || ""), items });
714
+ if (status === 200 || status === 201) {
715
+ return { ok: true, unchanged: Boolean(data.unchanged), count: Number(data.count) || 0,
716
+ snapshotHash: data.snapshot_hash || "", error: null, httpStatus: status };
671
717
  }
672
- return { ok: false, state: "", error: data.error || `http_${status}` };
718
+ this.onError(new Error(`HTTP ${status}`), { op: "publishActionCandidates", code: data.error || "http_error" });
719
+ return { ok: false, error: data.error || `http_${status}`, httpStatus: status };
673
720
  } catch (err) {
674
- this.onError(err, { op: "actionReconcile", code: "network_error" });
675
- return { ok: false, state: "", error: "network_error" };
721
+ this.onError(err, { op: "publishActionCandidates", code: "network_error" });
722
+ return { ok: false, error: "network_error", httpStatus: 0 };
676
723
  }
677
724
  }
678
725
 
package/element.d.ts CHANGED
@@ -16,8 +16,19 @@ export interface CxAiSuggestElement extends HTMLElement {
16
16
  /** 커스텀 엘리먼트 등록. 두 번 불러도 안전하고, 브라우저가 아니면 무동작(false). */
17
17
  export declare function defineCxAiSuggest(tagName?: string): boolean;
18
18
 
19
+ /** 처리 선택지 카드 태그 이름 */
20
+ export declare const ACTIONS_TAG: "cx-action-offers";
21
+ export interface CxActionOffersElement extends HTMLElement {
22
+ agent: import("./agent.js").CxAgent | null;
23
+ session: import("./agent.js").InquirySession | null;
24
+ actorClaimed: string;
25
+ }
26
+ /** `<cx-action-offers>` 등록 — 결과는 `cx-action-result` 이벤트(detail = ActionResult) */
27
+ export declare function defineCxActionOffers(tagName?: string): boolean;
28
+
19
29
  declare global {
20
- interface HTMLElementTagNameMap { "cx-ai-suggest": CxAiSuggestElement }
30
+ interface HTMLElementTagNameMap { "cx-ai-suggest": CxAiSuggestElement; "cx-action-offers": CxActionOffersElement }
31
+ interface HTMLElementEventMap { "cx-action-result": CustomEvent<import("./agent.js").ActionResult & { requestId: string }> }
21
32
  interface HTMLElementEventMap {
22
33
  "cx-adopt": CustomEvent<{ text: string; answerId: number | string | null }>;
23
34
  }
package/element.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * 클래스를 모듈 최상위에서 만들지 않는 이유: `HTMLElement` 가 없는 곳(SSR·Node)
25
25
  * 에서 import 만 해도 죽는다. 등록 시점에 만든다.
26
26
  */
27
- import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
27
+ import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
28
28
 
29
29
  export { aiSuggestView };
30
30
 
@@ -137,3 +137,126 @@ export function defineCxAiSuggest(tagName = TAG) {
137
137
  window.customElements.define(tagName, createClass());
138
138
  return true;
139
139
  }
140
+
141
+ // ── 처리 선택지 카드 — 표준 커스텀 엘리먼트 `<cx-action-offers>` ─────────────
142
+ //
143
+ // ```html
144
+ // <cx-action-offers></cx-action-offers>
145
+ // <script type="module">
146
+ // import { defineCxActionOffers } from "@fcg-labs/cx-agent-hook/element";
147
+ // defineCxActionOffers();
148
+ // const el = document.querySelector("cx-action-offers");
149
+ // el.agent = agent; el.session = agent.session(csId); el.actorClaimed = "상담사 이름";
150
+ // el.addEventListener("cx-action-result", (e) => console.log(e.detail.state));
151
+ // </script>
152
+ // ```
153
+ export const ACTIONS_TAG = "cx-action-offers";
154
+
155
+ function createActionsClass() {
156
+ return class CxActionOffersElement extends HTMLElement {
157
+ #agent = null;
158
+ #session = null;
159
+ #actorClaimed = "";
160
+ #offers = [];
161
+ #pending = [];
162
+ #confirming = null;
163
+ #ackedOffer = null; // 고위험 확인은 offer 단위
164
+
165
+ get agent() { return this.#agent; }
166
+ set agent(v) { this.#agent = v; this.#render(); }
167
+ get actorClaimed() { return this.#actorClaimed; }
168
+ set actorClaimed(v) { this.#actorClaimed = v == null ? "" : String(v); }
169
+ get session() { return this.#session; }
170
+ set session(s) {
171
+ if (this.#session && typeof this.#session.detachActionUi === "function") this.#session.detachActionUi();
172
+ this.#session = s || null;
173
+ this.#confirming = null; this.#ackedOffer = null;
174
+ this.#attach();
175
+ this.#render();
176
+ }
177
+ // sink 연결 — session setter 와 재연결(connectedCallback) 양쪽에서. detach 후 다시 DOM 에
178
+ // 붙는 엘리먼트가 화석 스냅샷으로 남지 않게 한다.
179
+ #attach() {
180
+ const s = this.#session;
181
+ if (s && typeof s.attachActionUi === "function") {
182
+ this.#offers = s.offers; this.#pending = s.pendingActions();
183
+ s.attachActionUi({ setOffers: (o, p) => { this.#offers = o; this.#pending = p; this.#render(); } });
184
+ } else { this.#offers = []; this.#pending = []; }
185
+ }
186
+ connectedCallback() { this.#attach(); this.#render(); }
187
+ disconnectedCallback() { if (this.#session && typeof this.#session.detachActionUi === "function") this.#session.detachActionUi(); }
188
+
189
+ async #execute(offerId) {
190
+ const offer = this.#offers.find((o) => o.offer_id === offerId);
191
+ if (!offer || !this.#session) return;
192
+ if (offer.risk_level === "high" && this.#ackedOffer !== offerId) return;
193
+ this.#confirming = null; this.#ackedOffer = null; this.#render();
194
+ const r = await this.#session.executeAction(offer, { actorClaimed: this.#actorClaimed });
195
+ this.#render();
196
+ this.dispatchEvent(new CustomEvent("cx-action-result", { detail: r, bubbles: true }));
197
+ }
198
+
199
+ #render() {
200
+ // 포커스 복원 — DOM 을 갈아 끼우므로 눌린 컨트롤(data-offer/data-request + action)을 다시 찾아 준다
201
+ const active = document.activeElement;
202
+ const focusKey = active && this.contains(active)
203
+ ? { action: active.dataset.action || "", offer: active.dataset.offer || "", request: active.dataset.request || "" } : null;
204
+ this.replaceChildren();
205
+ if (!this.#agent || !this.#session) return;
206
+ const tree = actionOffersTree({ offers: this.#offers, pending: this.#pending,
207
+ enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming, messages: this.#agent.messages });
208
+ if (!tree) return;
209
+ const actions = {
210
+ confirm: (e) => { this.#confirming = e.currentTarget.dataset.offer || null; this.#ackedOffer = null; this.#render(); },
211
+ cancel: () => { this.#confirming = null; this.#ackedOffer = null; this.#render(); },
212
+ ack: (e) => { const id = e.currentTarget.dataset.offer; this.#ackedOffer = this.#ackedOffer === id ? null : id; this.#render(); },
213
+ execute: (e) => this.#execute(e.currentTarget.dataset.offer),
214
+ resend: (e) => this.#session.resendResult(e.currentTarget.dataset.request),
215
+ };
216
+ const build = (node) => {
217
+ const dom = document.createElement(node.tag);
218
+ if (node.cls) dom.className = node.cls;
219
+ if (node.tag === "button") dom.type = node.type || "button";
220
+ if (node.action && actions[node.action]) {
221
+ dom.addEventListener("click", actions[node.action]);
222
+ dom.dataset.action = node.action;
223
+ if (node.offerId) dom.dataset.offer = node.offerId;
224
+ if (node.requestId) dom.dataset.request = node.requestId;
225
+ if (node.action === "execute" && this.#confirming) {
226
+ const o = this.#offers.find((x) => x.offer_id === node.offerId);
227
+ if (o && o.risk_level === "high" && this.#ackedOffer !== node.offerId) dom.disabled = true;
228
+ }
229
+ if (node.pressed) dom.setAttribute("aria-pressed", String(this.#ackedOffer === node.offerId));
230
+ }
231
+ if (node.live) dom.setAttribute("role", "status");
232
+ if (node.state) dom.dataset.state = node.state;
233
+ if (node.risk) dom.dataset.risk = node.risk;
234
+ if (node.children) for (const c of node.children) dom.append(build(c));
235
+ else if (node.text !== undefined) dom.textContent = node.text;
236
+ return dom;
237
+ };
238
+ const root = document.createElement("div");
239
+ root.className = tree.cls;
240
+ root.addEventListener("click", (e) => e.stopPropagation());
241
+ for (const n of tree.children) root.append(build(n));
242
+ this.append(root);
243
+ if (focusKey) {
244
+ // 같은 컨트롤이 남아 있으면 그것, 확인 패널을 연 직후면 패널의 첫 버튼(실행/체크)로
245
+ const same = focusKey.action && [...root.querySelectorAll("[data-action]")]
246
+ .find((el) => el.dataset.action === focusKey.action && (el.dataset.offer || "") === focusKey.offer && (el.dataset.request || "") === focusKey.request && !el.disabled);
247
+ const target = same
248
+ || (focusKey.action === "confirm" && root.querySelector(`.${"fcx-act-confirm"} button:not(:disabled)`))
249
+ || null;
250
+ if (target && typeof target.focus === "function") target.focus();
251
+ }
252
+ }
253
+ };
254
+ }
255
+
256
+ /** 커스텀 엘리먼트 등록 — 두 번 불러도 안전, 브라우저가 아니면 무동작. */
257
+ export function defineCxActionOffers(tagName = ACTIONS_TAG) {
258
+ if (typeof window === "undefined" || !window.customElements) return false;
259
+ if (window.customElements.get(tagName)) return false;
260
+ window.customElements.define(tagName, createActionsClass());
261
+ return true;
262
+ }
package/locales.js CHANGED
@@ -40,7 +40,10 @@ const en = {
40
40
  ui_action_succeeded: "Done",
41
41
  ui_action_failed: "Not applied",
42
42
  ui_action_unknown: "Could not confirm whether this was applied — check again before retrying.",
43
- ui_action_reconcile: "Check again",
43
+ ui_action_resend: "Report result again",
44
+ ui_action_unreported: "result not yet recorded",
45
+ ui_action_locked: "Already processed",
46
+ ui_action_unavailable: "This CMS does not know this action — ask your CMS admin to update the hook.",
44
47
  ui_action_disabled: "Actions are not enabled for this seat.",
45
48
  ui_action_risk_low: "low risk",
46
49
  ui_action_risk_medium: "medium risk",
@@ -80,7 +83,10 @@ const ko = {
80
83
  ui_action_succeeded: "처리 완료",
81
84
  ui_action_failed: "처리되지 않음",
82
85
  ui_action_unknown: "실행 여부를 확인하지 못했습니다 — 다시 누르지 말고 먼저 확인하세요.",
83
- ui_action_reconcile: "다시 확인",
86
+ ui_action_resend: "결과 다시 보고",
87
+ ui_action_unreported: "결과가 아직 기록되지 않음",
88
+ ui_action_locked: "이미 처리됨",
89
+ ui_action_unavailable: "이 CMS 에 없는 처리입니다 — CMS 담당자에게 훅 갱신을 요청하세요.",
84
90
  ui_action_disabled: "이 계정에는 처리 기능이 켜져 있지 않습니다.",
85
91
  ui_action_risk_low: "위험 낮음",
86
92
  ui_action_risk_medium: "위험 보통",
@@ -115,7 +121,10 @@ const ja = {
115
121
  ui_action_succeeded: "処理完了",
116
122
  ui_action_failed: "未処理",
117
123
  ui_action_unknown: "実行の有無を確認できませんでした — 再度押さずに先に確認してください。",
118
- ui_action_reconcile: "再確認",
124
+ ui_action_resend: "結果を再報告",
125
+ ui_action_unreported: "結果がまだ記録されていません",
126
+ ui_action_locked: "処理済み",
127
+ ui_action_unavailable: "このCMSにない処理です — CMS担当者にフック更新を依頼してください。",
119
128
  ui_action_disabled: "このアカウントでは処理機能が有効になっていません。",
120
129
  ui_action_risk_low: "リスク低",
121
130
  ui_action_risk_medium: "リスク中",
@@ -150,7 +159,10 @@ const zhTW = {
150
159
  ui_action_succeeded: "處理完成",
151
160
  ui_action_failed: "未套用",
152
161
  ui_action_unknown: "無法確認是否已執行 — 請先確認再重試。",
153
- ui_action_reconcile: "再次確認",
162
+ ui_action_resend: "重新回報結果",
163
+ ui_action_unreported: "結果尚未記錄",
164
+ ui_action_locked: "已處理",
165
+ ui_action_unavailable: "此 CMS 沒有這個處理 — 請 CMS 負責人更新掛鉤。",
154
166
  ui_action_disabled: "此帳號未啟用處理功能。",
155
167
  ui_action_risk_low: "風險低",
156
168
  ui_action_risk_medium: "風險中",
package/next.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Next.js (App Router) 진입점 — react 와 같되 "use client" 경계를 선언한다. */
2
- export { AiSuggestPanel, aiSuggestView, default } from "./react.js";
3
- export type { AiSuggestPanelProps, AiSuggestView } from "./react.js";
2
+ export { AiSuggestPanel, ActionOffersPanel, aiSuggestView, default } from "./react.js";
3
+ export type { AiSuggestPanelProps, ActionOffersPanelProps, AiSuggestView } from "./react.js";
package/next.js CHANGED
@@ -17,4 +17,4 @@
17
17
  * Pages Router 나 순수 React 라면 `@fcg-labs/cx-agent-hook/react` 와 같다.
18
18
  * 지시자는 그쪽에서 무시되므로 이 진입점을 써도 문제는 없다.
19
19
  */
20
- export { AiSuggestPanel, aiSuggestView, default } from "./react.js";
20
+ export { AiSuggestPanel, ActionOffersPanel, aiSuggestView, default } from "./react.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -61,6 +61,7 @@
61
61
  "agent.js",
62
62
  "agent.d.ts",
63
63
  "session.js",
64
+ "actions.js",
64
65
  "compat.js",
65
66
  "client.js",
66
67
  "client.d.ts",
@@ -83,7 +84,10 @@
83
84
  "element.d.ts",
84
85
  "styles.css",
85
86
  "README.md",
86
- "LICENSE"
87
+ "AGENTS.md",
88
+ "LICENSE",
89
+ "bin/",
90
+ "skills/"
87
91
  ],
88
92
  "scripts": {
89
93
  "test": "node --test test/*.test.js",
@@ -108,5 +112,8 @@
108
112
  "publishConfig": {
109
113
  "access": "public"
110
114
  },
111
- "//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가)."
112
- }
115
+ "//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가).",
116
+ "bin": {
117
+ "cx-agent-hook": "bin/cx-agent-hook.js"
118
+ }
119
+ }
package/react.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReactElement } from "react";
2
2
  import type { AnswerResult, CxHook } from "./index.js";
3
+ import type { ActionResult, CxAgent, InquirySession } from "./agent.js";
3
4
 
4
5
  export { aiSuggestView } from "./view.js";
5
6
  export type { AiSuggestView } from "./view.js";
@@ -17,4 +18,17 @@ export interface AiSuggestPanelProps {
17
18
  /** AI 답변 제안 패널. hook 이 미설정이면 아무것도 그리지 않는다. */
18
19
  export declare function AiSuggestPanel(props: AiSuggestPanelProps): ReactElement | null;
19
20
 
21
+
22
+ export interface ActionOffersPanelProps {
23
+ /** agent.session(externalId) — offers·pending·executeAction 의 소유자 */
24
+ session: InquirySession;
25
+ agent: CxAgent;
26
+ /** 표시용 행위자 이름(자기 주장) — 원장 actor_claimed */
27
+ actorClaimed?: string;
28
+ onResult?: (r: ActionResult & { requestId: string }) => void;
29
+ className?: string;
30
+ }
31
+ /** 처리 선택지 카드 (actions/1 프로파일 B) — 잠김·비활성·2단 확인·상태·미보고 규약을 갖는다. */
32
+ export declare function ActionOffersPanel(props: ActionOffersPanelProps): ReactElement | null;
33
+
20
34
  export default AiSuggestPanel;
package/react.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
21
21
 
22
- import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
22
+ import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
23
23
 
24
24
  // 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
25
25
  export { aiSuggestView };
@@ -97,3 +97,82 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
97
97
  }
98
98
 
99
99
  export default AiSuggestPanel;
100
+
101
+ // ── 처리 선택지 카드 (actions/1 프로파일 B) ───────────────────────────────
102
+ //
103
+ // 답변 패널과 같은 이유로 라이브러리에 있다: 카드 상태(잠김·비활성·확인 2단·
104
+ // 실행 중·완료·실패·확인 필요·미보고), 확인 문구, 재실행 금지 규약은 전부 제품
105
+ // 지식이다. 고객사는 배치만 한다 — 세션과 행위자 이름을 넘기면 끝.
106
+ //
107
+ // ```jsx
108
+ // import { ActionOffersPanel } from "@fcg-labs/cx-agent-hook/react";
109
+ // <ActionOffersPanel session={getCsSession(csId)} agent={agent} actorClaimed={answerWriter} />
110
+ // ```
111
+
112
+ /**
113
+ * @param {object} props
114
+ * @param {object} props.session agent.session(externalId) — offers·pending·executeAction 의 소유자
115
+ * @param {object} props.agent createCxAgent() 결과 — actionsEnabled·messages
116
+ * @param {string} [props.actorClaimed] 표시용 행위자 이름(자기 주장) — 원장에 남는다
117
+ * @param {(r: object) => void} [props.onResult] 실행 결과 콜백 ({requestId, state, reasonCode, message, reported})
118
+ * @param {string} [props.className] 바깥 배치용 (레이아웃만)
119
+ */
120
+ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, className }) {
121
+ const [offers, setOffers] = useState(() => (session ? session.offers : []));
122
+ const [pending, setPending] = useState(() => (session ? session.pendingActions() : []));
123
+ const [confirming, setConfirming] = useState(null);
124
+ const [ackedOffer, setAckedOffer] = useState(null); // 고위험 확인은 **offer 단위** — 다른 카드의 체크가 이월되지 않는다
125
+
126
+ // 세션이 offers·pending 을 밀어준다 — 답변이 오거나 실행 상태가 바뀔 때
127
+ useEffect(() => {
128
+ if (!session || typeof session.attachActionUi !== "function") return undefined;
129
+ session.attachActionUi({ setOffers: (o, p) => { setOffers(o); setPending(p); } });
130
+ return () => session.detachActionUi();
131
+ }, [session]);
132
+ useEffect(() => { setConfirming(null); setAckedOffer(null); }, [session]);
133
+
134
+ const actorRef = useRef(actorClaimed);
135
+ actorRef.current = actorClaimed;
136
+
137
+ const execute = useCallback(async (offerId) => {
138
+ const offer = offers.find((o) => o.offer_id === offerId);
139
+ if (!offer) return;
140
+ if (offer.risk_level === "high" && ackedOffer !== offerId) return; // 2단 확인 미완
141
+ setConfirming(null); setAckedOffer(null);
142
+ const r = await session.executeAction(offer, { actorClaimed: actorRef.current });
143
+ if (onResult) onResult(r);
144
+ }, [offers, ackedOffer, session, onResult]);
145
+
146
+ if (!session || !agent) return null;
147
+ const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming, messages: agent.messages });
148
+ if (!tree) return null;
149
+
150
+ const actions = {
151
+ confirm: (e) => { setConfirming(e.currentTarget.dataset.offer || null); setAckedOffer(null); },
152
+ cancel: () => { setConfirming(null); setAckedOffer(null); },
153
+ ack: (e) => { const id = e.currentTarget.dataset.offer; setAckedOffer((v) => (v === id ? null : id)); },
154
+ execute: (e) => execute(e.currentTarget.dataset.offer),
155
+ resend: (e) => session.resendResult(e.currentTarget.dataset.request),
156
+ };
157
+ const render = (node, key) => {
158
+ const props = { key, className: node.cls };
159
+ if (node.tag === "button") { props.type = node.type || "button"; }
160
+ if (node.action && actions[node.action]) {
161
+ props.onClick = actions[node.action];
162
+ if (node.offerId) props["data-offer"] = node.offerId;
163
+ if (node.requestId) props["data-request"] = node.requestId;
164
+ if (node.action === "execute" && confirming) {
165
+ const o = offers.find((x) => x.offer_id === node.offerId);
166
+ if (o && o.risk_level === "high" && ackedOffer !== node.offerId) props.disabled = true;
167
+ }
168
+ if (node.pressed) props["aria-pressed"] = ackedOffer === node.offerId;
169
+ }
170
+ if (node.live) props.role = "status";
171
+ if (node.state) props["data-state"] = node.state;
172
+ if (node.risk) props["data-risk"] = node.risk;
173
+ return h(node.tag, props, node.children ? node.children.map((c, i) => render(c, i)) : node.text);
174
+ };
175
+ return h("div", { className: className ? `${tree.cls} ${className}` : tree.cls,
176
+ onClick: (event) => event.stopPropagation() },
177
+ tree.children.map((n, i) => render(n, i)));
178
+ }