@validflow/ccneed-cli 0.1.6 → 0.1.7

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,71 @@
1
+ # 采集方案与确认
2
+
3
+ 先检查安装版本的 `ccneed kb plan --help`、`ccneed kb start --help`。命令不存在或服务接口未部署时明确说明,不绕过成 HTTP 写请求。采集不会因为用户查看方案、余额或已有数据而开始。
4
+
5
+ ## 直接获取数据
6
+
7
+ 1. 宿主根据完整研究需求生成结构化方案;不调用网页的“优化意图/解析意图”。用户未指定数量时,所有平台合计默认 **300 条**;明确的总量或分平台数量优先。
8
+ 2. 把方案写入普通 JSON 文件,用 `ccneed kb plan --plan-file ./plan.json --output ./prepared.json` 交平台校验、分配每词数量、计算积分并比较余额。这一步不创建知识库、不扣款、不采集。
9
+ 3. plugin 的 PostToolUse hook 向用户展示平台返回的最终方案,`--output` 同时完整保存返回 JSON,不能只保存 token 后自行改展示字段。stdout 保留给 hook;不要用 shell 重定向隐藏方案。`invalid_plan` 的字段错误应修复后重新提交,不把校验失败当作已准备好。
10
+ 4. 用户可修改需求、平台、关键词或配额;修改后重新调用 `kb plan`,展示新结果。`insufficient_credits`、无 token、过期或校验失败都不能执行。
11
+ 5. 使用同一 prepared 文件执行 `ccneed kb start --plan-file ./prepared.json --request-id REQUEST_UUID`。这条命令必须独立调用,由宿主原生权限 UI 请求本次执行授权;用户拒绝即不执行。接收未知时保留同一 request ID,先查状态,不换 ID 重发。
12
+ 6. 按返回的真实 KB ID 查询采集状态;完成后使用 `kb export`。只需数据的用户不必创建云端分析会话、打标需求或报告任务。
13
+
14
+ 示例输入结构(使用用户的真实研究需求与词,不照抄样例):
15
+
16
+ ```json
17
+ {
18
+ "research_requirement": "研究某品牌产品的用户体验与购买顾虑",
19
+ "kb_name": "某品牌用户反馈",
20
+ "content_scope": "both",
21
+ "platforms": [
22
+ {
23
+ "platform": "xhs",
24
+ "quota": 150,
25
+ "keywords": [
26
+ {"keyword": "某品牌产品", "tier": 1, "keyword_type": "entity"},
27
+ {"keyword": "某品牌产品 使用体验", "tier": 1, "keyword_type": "preferred"},
28
+ {"keyword": "某品牌产品 购买顾虑", "tier": 2, "keyword_type": "generated"}
29
+ ]
30
+ },
31
+ {
32
+ "platform": "weibo",
33
+ "quota": 150,
34
+ "keywords": [
35
+ {"keyword": "某品牌产品", "tier": 1, "keyword_type": "entity"},
36
+ {"keyword": "某品牌产品 评价", "tier": 2, "keyword_type": "generated"}
37
+ ]
38
+ }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ 补采集使用真实 `kb_id`,保留完整研究需求。平台负责验证所有权;不能因同名知识库猜 ID。
44
+
45
+ ## Agent 生成的内部方案
46
+
47
+ T1/T2/T3 是宿主与平台的内部字段,不向用户解释术语或逐词数量。T1 包含必须保留的实体词,以及与需求有关的用户偏好词。沿用平台分配:T1 占 80%,有实体和偏好词时内部为 30%/70%;剩余 20% 按 T2/T3 每词 3:2 分配,缺组时由现有组承接。平台完成最小配额、取整和总量校验,以返回方案为准。
48
+
49
+ 删除偏好词或其他关键词后,由平台重新平摊配额;实体锚点不能删除。用户新加词归 T2,之后重新校验、报价、展示。不能为了修复 schema 或余额错误而静默丢掉研究对象、扩大预算或直接启动。
50
+
51
+ ## 用户看到的方案
52
+
53
+ 展示完整研究需求,然后每个平台一行:**实体词、其他关键词、整个平台多少条**。其他关键词包括 T1 偏好词与 T2/T3;不展示 tier、ratio、逐词 quota、token 或其他内部字段。再显示总条数、预计积分、当前余额及平台做过的缩量/修改说明。
54
+
55
+ 这是最终执行方案。用户可继续调整;原生权限确认紧接实际执行,不能拿前一版展示为新一版授权。展示成功也不等于用户已经授权。
56
+
57
+ ## 云端 Agent 对话中的采集
58
+
59
+ `tasks get/watch` 返回采集类 `waiting_user` 时,plugin 同样展示 `pending_action.suggested` 的研究需求、平台和配额。按当前 `resume_schema` 保存答案,修改方案先重新校验。真正批准创建/补采集的 `tasks reply` 也须触发原生权限;`response_kind: user_feedback/user_rejected` 是反馈,不应当成批准执行。
60
+
61
+ 其他 AskUser 澄清仍按[会话与任务](tasks.md)处理,不把所有 HITL 都改成采集授权。
62
+
63
+ ## 宿主接入边界
64
+
65
+ 安装/升级后重新加载宿主,并核对 hook 已加载、原生权限策略有效。CLI 本身是 Bash/执行命令工具运行的子进程,不能在 CLI 内伪造宿主权限弹窗。
66
+
67
+ - Claude Code:PreToolUse 可请求 `ask`;禁止用 bypass/dontAsk/自动批准模式执行采集。
68
+ - Codex:使用 `prefix_rule(..., decision="prompt")`。该规则保护受支持的沙箱外执行路径;付费命令必须通过人工审批的该路径执行。PreToolUse 的 `ask` 不受支持,不能返回它假装有效。禁用 hook/rules、忽略配置或自动审批时不得继续采集。
69
+ - WorkBuddy:桌面端使用 `~/.workbuddy` 的 Skill 与 hooks,付费采集使用同一宿主的**原生 Bash 工具**,保持默认权限与现有沙箱。沙箱内由安装器添加的 `sandbox.commandSafety.ask` 请求原生确认;当前 PowerShell 的安全删除包装不能可靠命中该规则,因此付费执行会拒绝并提示改用 Bash,不关闭沙箱。每次选择“仅允许本次”;选择“本会话不再询问”会主动扩大授权,需新建宿主会话恢复逐次确认。旧 `.codebuddy` 本地代码配置不代表桌面端已加载。
70
+
71
+ 三个宿主都不能以“配置文件存在”代替真实加载与权限验收,也不能在用户关闭 hook 或绕过全部权限后声称仍能强制确认。
@@ -2,7 +2,7 @@
2
2
 
3
3
  以下命令已随 CLI 发行并有本地契约测试;具体云端能力仍取决于正式服务接口是否上线。示例 `THREAD_UUID`、`FILE_UUID`、`ARTIFACT_UUID`、`VERSION` 必须换成服务实际返回值;知识库用正整数 ID,文件/会话/产出用真实 UUID。
4
4
 
5
- ## 知识库:查询与网页创建
5
+ ## 知识库:查询、采集与原生数据导出
6
6
 
7
7
  ```bash
8
8
  ccneed kb list
@@ -15,7 +15,16 @@ ccneed kb create
15
15
 
16
16
  `stats` 只返回已发布、可分析内容的总数、平台与情绪分布、最多八个主话题,以及同环境的 `web_url`。它不返回帖子正文、单条来源或批量数据。用于选库和判断覆盖范围;如果用户要研究结论,仍需 `ccneed run ... --kb-id 7` 交给 CCNeed 云端分析。需要在网页浏览具体内容时,把 `web_url` 交给用户,不通过其他网页接口绕过 CLI 的聚合边界。采集中的数据尚未发布时,统计可能暂时为零,不把零解释为采集失败。
17
17
 
18
- `kb create` 只返回 `status: website_required` 和当前 origin 的官网入口 `url`,**没有创建知识库或启动采集**。向用户展示链接,把意图优化、平台/配额选择和确认交给网页;完成后重新 `kb list/get` 获取真实 ID 和状态,不伪造知识库。
18
+ `kb create` 保留原来的网页入口。用户希望在宿主内直接创建或补采集时,走[采集方案与确认](acquisition.md)中的 `kb plan` → 展示 → `kb start`,不要求再打开网页确认。
19
+
20
+ 需要让用户自己的 Agent 分析时,直接导出已有知识库:
21
+
22
+ ```bash
23
+ ccneed kb export --kb-id 7 --format csv
24
+ ccneed kb export --kb-id 7 --format json --output ./brand-feedback.json
25
+ ```
26
+
27
+ 两种格式来自同一份知识库数据,不自动采集、补充作者信息或启动报告任务。未指定路径时使用 `ccneed-kb-{KB_ID}-{安全知识库名}-{UTC时间戳}.{csv|json}`,例如 `ccneed-kb-7-某品牌用户反馈-20261106T073025Z.csv`。保留返回的 KB ID、名称、格式、导出时间与本地路径,以免混淆多个知识库。目标存在会报错,不覆盖旧数据;自行指定路径也必须不存在。
19
28
 
20
29
  ## 文件:先上传,再绑定
21
30
 
@@ -0,0 +1,351 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ const hosts = ['codex', 'claude', 'workbuddy'];
8
+ const platformNames = { xhs: '小红书', weibo: '微博', douyin: '抖音', bilibili: '哔哩哔哩', twitter: 'X', reddit: 'Reddit', tiktok: 'TikTok', youtube: 'YouTube', instagram: 'Instagram', lemon8: 'Lemon8' };
9
+ const defaultStateDir = join(tmpdir(), 'ccneed-hooks-' + createHash('sha256').update(homedir()).digest('hex').slice(0, 12));
10
+
11
+ /** plainText 将外部文本当作展示数据,移除终端控制字符并转义 Markdown。
12
+ * Args:
13
+ * value: 平台返回的文本。
14
+ * Returns:
15
+ * 不含终端控制符或活动链接的文本。
16
+ */
17
+ function plainText(value) {
18
+ return String(value ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '').replace(/([\\`*_{}\[\]()#+.!<>|])/g, '\\$1').replace(/\r?\n/g, ' ');
19
+ }
20
+
21
+ /** displayedPlatforms 从直接计划或云端卡片提取同一份用户投影。
22
+ * Args:
23
+ * plan: 平台校验结果或采集卡片。
24
+ * Returns:
25
+ * 按整个平台汇总的实体词、其他词与配额。
26
+ * Errors:
27
+ * 没有可展示的完整方案时返回异常。
28
+ */
29
+ function displayedPlatforms(plan) {
30
+ const platforms = Array.isArray(plan.platforms) ? plan.platforms : Object.entries(plan.quotas ?? {}).map(([platform, quota]) => {
31
+ const rows = (plan.search_plan ?? []).filter(row => row.platform === platform);
32
+ const entity = row => row.keyword_type === 'entity' || row.strategy_rationale === 'entity anchor';
33
+ return { platform, quota, entity_keywords: rows.filter(entity).map(row => row.keyword), other_keywords: rows.filter(row => !entity(row)).map(row => row.keyword) };
34
+ });
35
+ if (!platforms.length || platforms.some(row => !row.platform || !Number.isSafeInteger(row.quota) || row.quota <= 0 || !Array.isArray(row.entity_keywords) || !row.entity_keywords.length || !Array.isArray(row.other_keywords))) throw new Error('Collection plan is incomplete');
36
+ return platforms;
37
+ }
38
+
39
+ /** renderPlan 仅展示用户关心的研究需求、平台汇总和积分,不显示内部 tier。
40
+ * Args:
41
+ * plan: 平台返回的最终采集方案。
42
+ * Returns:
43
+ * 可以直接显示在宿主 UI 的 Markdown。
44
+ * Errors:
45
+ * 研究需求或平台方案缺失时返回异常。
46
+ */
47
+ export function renderPlan(plan) {
48
+ const requirement = plan.research_requirement ?? plan.kb_description;
49
+ if (typeof requirement !== 'string' || !requirement.trim()) throw new Error('Research requirement is missing');
50
+ const platforms = displayedPlatforms(plan);
51
+ const rows = platforms.map(row => `| ${plainText(platformNames[row.platform] ?? row.platform)} | ${row.entity_keywords.map(plainText).join('、')} | ${row.other_keywords.map(plainText).join('、') || '—'} | ${row.quota} |`);
52
+ const total = platforms.reduce((sum, row) => sum + row.quota, 0);
53
+ const credits = Number.isSafeInteger(plan.estimated_credits) ? String(plan.estimated_credits) : '暂不可用';
54
+ const balance = Number.isSafeInteger(plan.balance) ? String(plan.balance) : '暂不可用';
55
+ const adjustments = Array.isArray(plan.adjustments) ? plan.adjustments.map(value => '- ' + plainText(typeof value === 'string' ? value : value.message ?? JSON.stringify(value))) : [];
56
+ return ['### 采集方案', '', '**研究需求**', '', plainText(requirement), '', '| 平台 | 实体词 | 其他关键词 | 采集上限(条) |', '| --- | --- | --- | ---: |', ...rows, '', `**合计 ${total} 条 · 预计 ${credits} 积分 · 当前余额 ${balance} 积分**`, ...adjustments, '', '可继续调整方案;正式采集前还需在宿主权限窗口确认。'].join('\n');
57
+ }
58
+
59
+ /** canonical 稳定排列 JSON 键,允许文件缩进和键序变化而不允许内容变化。
60
+ * Args:
61
+ * value: 有界 JSON 值。
62
+ * Returns:
63
+ * 可用于计算展示版本摘要的规范 JSON 值。
64
+ */
65
+ function canonical(value) {
66
+ if (Array.isArray(value)) return value.map(canonical);
67
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
68
+ return value;
69
+ }
70
+
71
+ /** fingerprint 为展示方案生成摘要,不持久化 token 或正文。
72
+ * Args:
73
+ * value: 平台返回的最终方案。
74
+ * Returns:
75
+ * SHA-256 内容摘要。
76
+ */
77
+ function fingerprint(value) { return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); }
78
+
79
+ /** readObject 读取有界普通 JSON 文件,不跟随文件链接。
80
+ * Args:
81
+ * path: 明确的方案、答案或状态路径。
82
+ * Returns:
83
+ * JSON 对象。
84
+ * Errors:
85
+ * 非普通文件、过大或非对象 JSON 返回异常。
86
+ */
87
+ function readObject(path) {
88
+ const stat = lstatSync(path);
89
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) throw new Error('Expected a bounded regular JSON file');
90
+ const value = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
91
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a JSON object');
92
+ return value;
93
+ }
94
+
95
+ /** parseCommand 只接受明确的单条 ccneed 调用,拒绝包装、插值和隐藏输出。
96
+ * Args:
97
+ * command: 宿主提供的实际命令文本。
98
+ * Returns:
99
+ * 规范 argv;无关命令返回 null。
100
+ * Errors:
101
+ * 含 ccneed 的复合命令或动态 shell 语法返回异常。
102
+ */
103
+ function parseCommand(command) {
104
+ if (typeof command !== 'string' || !/\bccneed(?:\.cmd|\.exe)?(?=["']?\s|$)/i.test(command)) return null;
105
+ if (/[;&|<>`$\r\n]/.test(command)) throw new Error('Run one plain ccneed command without wrappers, redirection or interpolation');
106
+ const matcher = /\s*(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s"']+))/gy;
107
+ const args = [];
108
+ let offset = 0, token;
109
+ while ((token = matcher.exec(command))) {
110
+ args.push(token[1] ?? token[2] ?? token[3]);
111
+ offset = matcher.lastIndex;
112
+ if (offset < command.length && !/\s/.test(command[offset])) throw new Error('Use separated literal command arguments');
113
+ }
114
+ if (command.slice(offset).trim()) throw new Error('Unclosed command quote');
115
+ if (!/^ccneed(?:\.cmd|\.exe)?$/.test(args[0] ?? '')) throw new Error('Use the lowercase ccneed command directly so native permission rules match');
116
+ if (args[1]?.startsWith('-') && !(args.length === 2 && ['--help', '-h'].includes(args[1]))) throw new Error('Place connection flags after the subcommand so native permission rules match');
117
+ return args;
118
+ }
119
+
120
+ /** flagValue 读取唯一的显式参数,避免检查与实际解析不同的重复值。
121
+ * Args:
122
+ * args: 已校验的 ccneed argv。
123
+ * flag: 完整长参数名。
124
+ * Returns:
125
+ * 对应参数值或空字符串。
126
+ * Errors:
127
+ * 重复参数返回异常。
128
+ */
129
+ function flagValue(args, flag) {
130
+ const values = args.flatMap((value, index) => value === flag ? [args[index + 1] ?? ''] : value.startsWith(flag + '=') ? [value.slice(flag.length + 1)] : []);
131
+ if (values.length > 1) throw new Error('Repeated command flag');
132
+ return values[0] ?? '';
133
+ }
134
+
135
+ /** responseObject 适配宿主结构化输出与 stdout 信封,不读取 transcript。
136
+ * Args:
137
+ * response: 宿主返回的工具结果。
138
+ * Returns:
139
+ * 公开 CLI JSON 或 null。
140
+ */
141
+ function responseObject(response) {
142
+ if (!response) return null;
143
+ if (typeof response === 'object') {
144
+ if (response.platforms || response.pending_action || ['ready', 'insufficient_credits', 'invalid_plan'].includes(response.status)) return response;
145
+ if (Array.isArray(response)) return responseObject(response.filter(item => item.type === 'text').map(item => item.text).join('\n'));
146
+ if (response.stdout !== undefined) return responseObject(response.stdout);
147
+ if (response.output !== undefined) return responseObject(response.output);
148
+ if (Array.isArray(response.content)) return responseObject(response.content.filter(item => item.type === 'text').map(item => item.text).join('\n'));
149
+ return null;
150
+ }
151
+ if (typeof response !== 'string' || response.length > 4 * 1024 * 1024) return null;
152
+ const first = response.indexOf('{');
153
+ const last = response.lastIndexOf('}');
154
+ if (first < 0 || last < first) return null;
155
+ try { return JSON.parse(response.slice(first, last + 1)); } catch { return null; }
156
+ }
157
+
158
+ /** stateAccess 为单个宿主会话提供短期展示记录,不把它当成人工授权。
159
+ * Args:
160
+ * host: 当前宿主名称。
161
+ * input: 原始 hook 事件。
162
+ * stateDir: 私有临时状态目录。
163
+ * Returns:
164
+ * 当前记录和可原子保存记录的函数。
165
+ * Errors:
166
+ * 会话 ID 缺失、状态目录链接或损坏记录返回异常。
167
+ */
168
+ function stateAccess(host, input, stateDir) {
169
+ if (typeof input.session_id !== 'string' || !input.session_id) throw new Error('Hook session identity is missing');
170
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
171
+ const stat = lstatSync(stateDir);
172
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Hook state must be a plain directory');
173
+ const path = join(stateDir, fingerprint([host, input.session_id]) + '.json');
174
+ let state = {};
175
+ try { state = readObject(path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
176
+ const save = value => {
177
+ const temporary = path + '.' + process.pid + '.tmp';
178
+ writeFileSync(temporary, JSON.stringify(value), { flag: 'wx', mode: 0o600 });
179
+ renameSync(temporary, path);
180
+ };
181
+ return { state, save };
182
+ }
183
+
184
+ /** deny 通过受支持的原生拒绝协议阻止付费工具执行。
185
+ * Args:
186
+ * reason: 可公开的拒绝原因。
187
+ * Returns:
188
+ * 不会被解释为自动批准的 hook 输出。
189
+ */
190
+ function deny(reason) {
191
+ return { systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
192
+ }
193
+
194
+ /** planFile 以明确路径读取待执行方案;Codex 不接受不可靠的相对工作目录。
195
+ * Args:
196
+ * host: 当前宿主。
197
+ * input: 原始 hook 事件。
198
+ * args: 已校验的命令 argv。
199
+ * flag: 需要读取的文件参数。
200
+ * Returns:
201
+ * 有界 JSON 文件内容。
202
+ * Errors:
203
+ * 缺少文件或 Codex 相对路径时返回异常。
204
+ */
205
+ function planFile(host, input, args, flag) {
206
+ const path = flagValue(args, flag);
207
+ if (!path || (host === 'codex' && !isAbsolute(path))) throw new Error('Use an absolute JSON file path for Codex collection commands');
208
+ return readObject(resolve(input.cwd ?? process.cwd(), path));
209
+ }
210
+
211
+ /** postTool 呈现平台最终方案,保存同会话版本并分类云端 HITL。
212
+ * Args:
213
+ * host: 当前宿主。
214
+ * input: 原始 hook 事件。
215
+ * args: 规范命令 argv。
216
+ * access: 当前会话展示记录。
217
+ * Returns:
218
+ * 用户可见 Markdown 或空输出。
219
+ */
220
+ function postTool(host, input, args, access) {
221
+ const response = responseObject(input.tool_response);
222
+ if (args[1] === 'kb' && args[2] === 'plan') {
223
+ access.save({ ...access.state, plan: null });
224
+ if (!response) return { systemMessage: '未收到完整采集校验结果,请重新准备方案;本次不能启动。' };
225
+ if (response.status === 'invalid_plan') return { systemMessage: ['### 采集方案需要修改', ...(response.errors ?? []).map(error => '- ' + plainText(error.field) + ':' + plainText(error.message))].join('\n') };
226
+ const message = renderPlan(response);
227
+ access.save({ ...access.state, plan: response.status === 'ready' && response.plan_token ? fingerprint([flagValue(args, '--api-url') || 'https://ccneed.ai', response]) : null });
228
+ return { systemMessage: message };
229
+ }
230
+ const action = response?.pending_action;
231
+ if (!action?.action_id) return {};
232
+ const suggested = action.suggested;
233
+ const acquisition = suggested && ['create', 'add'].includes(suggested.mode) && suggested.quotas;
234
+ access.save({ ...access.state, action: null });
235
+ const message = acquisition ? renderPlan(suggested) : null;
236
+ const anchors = [...new Set((suggested?.search_plan ?? []).filter(row => row.strategy_rationale === 'entity anchor').map(row => row.keyword))];
237
+ const inputs = { ...suggested, ...(anchors.length ? { keywords: anchors } : {}), ...(suggested?.plan_token ? { keyword_plan_token: suggested.plan_token } : {}), ...(suggested?.time_range ? { date_range: suggested.time_range } : {}) };
238
+ const fields = Object.fromEntries(Object.entries(inputs).map(([key, value]) => [key, fingerprint(value)]));
239
+ const pricingReady = Number.isSafeInteger(suggested?.estimated_credits) && suggested.estimated_credits >= 0 && Number.isSafeInteger(suggested?.balance) && suggested.balance >= suggested.estimated_credits;
240
+ access.save({ ...access.state, action: { id: action.action_id, thread_id: response.thread_id, turn_id: response.turn_id, origin: flagValue(args, '--api-url') || 'https://ccneed.ai', acquisition: Boolean(acquisition), pricingReady, fields } });
241
+ return message ? { systemMessage: message } : {};
242
+ }
243
+
244
+ /** codexConfigAllowsManual 排除配置中已知的免确认和自动审批设置,不改写安全模式。
245
+ * Args:
246
+ * input: 当前 hook 事件。
247
+ * Returns:
248
+ * 未发现已知不受支持配置时为真。
249
+ */
250
+ function codexConfigAllowsManual(input) {
251
+ if (input.approvals_reviewer === 'auto_review' || input.approval_policy === 'never') return false;
252
+ const paths = [join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml'), join(input.cwd ?? process.cwd(), '.codex', 'config.toml')];
253
+ for (const path of paths) {
254
+ let text;
255
+ try { text = readFileSync(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') continue; return false; }
256
+ const root = text.split(/^\s*\[/m)[0];
257
+ if (/^\s*(?:approval_policy\s*=\s*['"]never['"]|approvals_reviewer\s*=\s*['"]auto_review['"])/m.test(root)) return false;
258
+ if (/^\s*approval_policy\s*=.*\brules\s*=\s*false/m.test(root)) return false;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ /** preTool 将付费执行绑定到已展示版本,并要求宿主原生权限确认。
264
+ * Args:
265
+ * host: 当前宿主。
266
+ * input: 原始 hook 事件。
267
+ * args: 规范命令 argv。
268
+ * access: 当前会话展示记录。
269
+ * Returns:
270
+ * 原生确认、明确拒绝或不干预结果。
271
+ * Errors:
272
+ * 文件或参数无效时由调用方转换为原生拒绝。
273
+ */
274
+ function preTool(host, input, args, access) {
275
+ let message;
276
+ if (args[1] === 'kb' && args[2] === 'start') {
277
+ const plan = planFile(host, input, args, '--plan-file');
278
+ if (plan.status !== 'ready' || !plan.plan_token || fingerprint([flagValue(args, '--api-url') || 'https://ccneed.ai', plan]) !== access.state.plan) return deny('先通过 kb plan 在同一服务环境展示本次最终方案;修改、换版或切换环境后必须重新展示。');
279
+ if (!Number.isFinite(Date.parse(plan.expires_at)) || Date.parse(plan.expires_at) <= Date.now()) return deny('采集方案已过期,请重新校验、报价并展示后再确认。');
280
+ if (!Number.isSafeInteger(plan.estimated_credits) || !Number.isSafeInteger(plan.balance) || plan.estimated_credits > plan.balance) return deny('当前方案无法确认积分与余额,请重新校验并展示。');
281
+ message = renderPlan(plan);
282
+ } else if (args[1] === 'tasks' && args[2] === 'reply') {
283
+ const answer = planFile(host, input, args, '--answer-file');
284
+ const action = access.state.action;
285
+ if (!action || action.id !== flagValue(args, '--action-id') || action.thread_id !== flagValue(args, '--thread-id') || action.turn_id !== flagValue(args, '--turn-id') || action.origin !== (flagValue(args, '--api-url') || 'https://ccneed.ai')) return deny('先通过 tasks get/watch 在同一服务环境读取并展示当前待确认项,再回答同一个 action。');
286
+ if (!action.acquisition || answer.decision === 'rejected' || ['user_feedback', 'user_rejected'].includes(answer.response_kind)) return {};
287
+ if (!action.pricingReady) return deny('当前采集方案无法确认积分与余额,或余额不足。请先反馈给云端 Agent,重新校验、调整并展示方案后再批准。');
288
+ if (answer.modified_input && Object.entries(answer.modified_input).some(([key, value]) => !(key === 'user_reply' && typeof value === 'string') && fingerprint(value) !== action.fields?.[key])) return deny('采集方案发生修改,请先反馈给云端 Agent,取得并展示重新校验的采集卡片后再批准。');
289
+ message = '批准当前已展示的采集方案,将创建或补充知识库并使用积分。';
290
+ } else return {};
291
+ if (!['default', 'acceptEdits'].includes(input.permission_mode)) return deny('正式采集需要宿主的人工权限确认;请关闭完全访问、自动批准或免确认模式后重试。');
292
+ if (host === 'workbuddy' && input.tool_name !== 'Bash') return deny('WorkBuddy 正式采集请使用同一宿主的原生 Bash 工具。当前 PowerShell 沙箱路径无法保证采集前确认;无需关闭沙箱或修改安全删除保护。');
293
+ if (host === 'codex') {
294
+ if (!codexConfigAllowsManual(input)) return deny('Codex 当前启用了免确认或自动审批配置,请使用人工审批模式后再采集。');
295
+ return { systemMessage: message, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '本次命令必须命中 CCNeed 原生 prompt 规则,并由人工确认;不要设置自动 reviewer 或忽略 rules。' } };
296
+ }
297
+ return { systemMessage: message, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: '确认本次知识库采集与预计积分支出' } };
298
+ }
299
+
300
+ /** handleHook 为三种宿主处理同一套展示和执行版本约束。
301
+ * Args:
302
+ * host: claude、codex 或 workbuddy。
303
+ * input: 宿主传入的 JSON hook 事件。
304
+ * options: 可注入隔离状态目录。
305
+ * Returns:
306
+ * 当前宿主支持的 JSON hook 输出。
307
+ */
308
+ export function handleHook(host, input, { stateDir = defaultStateDir } = {}) {
309
+ if (!hosts.includes(host)) return deny('未知的 CCNeed plugin 宿主。');
310
+ if (input.hook_event_name === 'SessionStart') return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'CCNeed 采集 hooks 已加载。先 kb plan 展示最终方案,再通过人工原生权限确认 kb start;免确认模式禁止采集。' + (host === 'workbuddy' ? ' WorkBuddy 的付费采集使用原生 Bash 工具,每次选择“仅允许本次”;不要选择本会话不再询问。' : '') } };
311
+ try {
312
+ const args = parseCommand(input.tool_input?.command ?? input.tool_input?.cmd);
313
+ if (!args) return {};
314
+ if (args.length <= 4 && ['--help', '-h'].includes(args.at(-1)) && args.slice(1, -1).every(value => /^[a-z][a-z-]*$/.test(value))) return {};
315
+ const access = stateAccess(host, input, stateDir);
316
+ if (input.hook_event_name === 'PostToolUse') return postTool(host, input, args, access);
317
+ if (input.hook_event_name === 'PreToolUse') return preTool(host, input, args, access);
318
+ return {};
319
+ } catch {
320
+ return input.hook_event_name === 'PreToolUse' ? deny('CCNeed 无法验证命令、已展示方案或确认文件。请使用独立命令、完整方案与明确文件路径,重新准备并展示后再执行。') : { systemMessage: 'CCNeed 未能完整展示采集方案,本次方案不可直接启动。请重新读取校验结果。' };
321
+ }
322
+ }
323
+
324
+ /** runHookFromStdin 执行宿主的有界 JSON 协议,保持三种原生入口共用同一实现。
325
+ * Args:
326
+ * host: 当前原生插件指定的宿主名称。
327
+ * Returns:
328
+ * 协议处理完成后的 Promise;结果 JSON 写入 stdout。
329
+ * Errors:
330
+ * 无效输入写入固定错误信息并设置退出码 2,不回显输入正文。
331
+ */
332
+ export async function runHookFromStdin(host) {
333
+ try {
334
+ const chunks = [];
335
+ let size = 0;
336
+ for await (const chunk of process.stdin) {
337
+ size += chunk.length;
338
+ if (size > 4 * 1024 * 1024) throw new Error('Hook input too large');
339
+ chunks.push(chunk);
340
+ }
341
+ process.stdout.write(JSON.stringify(handleHook(host, JSON.parse(Buffer.concat(chunks).toString('utf8')))) + '\n');
342
+ } catch {
343
+ process.stderr.write('CCNeed hook input could not be verified; collection must not proceed.\n');
344
+ process.exitCode = 2;
345
+ }
346
+ }
347
+
348
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
349
+ const hostIndex = process.argv.indexOf('--host');
350
+ await runHookFromStdin(hostIndex < 0 ? '' : process.argv[hostIndex + 1]);
351
+ }