@xgent-ai/skills 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.
- package/README.md +24 -2
- package/bin/xgent-skills.js +230 -16
- package/docs/claude-context-goal-guard.md +125 -0
- package/docs/codex-context-goal-guard.md +110 -0
- package/hooks/claude/context-goal-guard.js +139 -0
- package/hooks/claude/settings.example.json +26 -0
- package/hooks/codex/context-goal-guard.py +189 -0
- package/hooks/codex/hooks.example.json +26 -0
- package/package.json +7 -1
- package/skills/dev-plan/SKILL.md +8 -6
- package/skills/dev-plan/references/plan-skeleton.md +5 -5
- package/skills/dev-plan/scripts/check_progress.sh +2 -2
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ npx skills add XGENT-ai/skills
|
|
|
16
16
|
npx skills add XGENT-ai/skills --skill dev-plan
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
## Claude Code
|
|
19
|
+
## 项目辅助工具(Claude Code / Codex)
|
|
20
20
|
|
|
21
|
-
本仓库同时以 [`@xgent-ai/skills`](https://www.npmjs.com/package/@xgent-ai/skills) 发布到 npm,自带 `xgent-skills` 命令,可为任意项目安装 XGENT 的 Claude Code hooks(当前包含 statusline)、在项目 `.claude/settings.json` 中启用对应配置,装上随包 vendor 的 [impeccable](#vendor-的-impeccable),并可选创建通用的 `AGENTS.md
|
|
21
|
+
本仓库同时以 [`@xgent-ai/skills`](https://www.npmjs.com/package/@xgent-ai/skills) 发布到 npm,自带 `xgent-skills` 命令,可为任意项目安装 XGENT 的 Claude Code hooks(当前包含 statusline)、在项目 `.claude/settings.json` 中启用对应配置,装上随包 vendor 的 [impeccable](#vendor-的-impeccable),并可选创建通用的 `AGENTS.md`、安装 Codex 或 Claude Code 的 goal 上下文收尾提醒:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
# 在目标项目根目录执行(也可显式传目录:npx @xgent-ai/skills install <dir>)
|
|
@@ -32,6 +32,12 @@ npx @xgent-ai/skills install
|
|
|
32
32
|
| `--no-impeccable` | 跳过 impeccable,仍安装 XGENT 的 hooks 与 settings |
|
|
33
33
|
| `--xgent-init` | 直接创建仓根 `AGENTS.md` (已有则跳过),不再询问 |
|
|
34
34
|
| `--no-xgent-init` | 跳过创建 `AGENTS.md`,不再询问 |
|
|
35
|
+
| `--context-goal-guard` | 直接安装 Codex goal 上下文收尾提醒,不再询问;需要 Python 3.9+ |
|
|
36
|
+
| `--no-context-goal-guard` | 跳过 Codex goal guard,不再询问;已有 guard 保留 |
|
|
37
|
+
| `--context-goal-guard-threshold=N` | Codex guard 的触发阈值百分比(1–99 整数,默认 65),同时表示安装;重装不给则沿用已装的值 |
|
|
38
|
+
| `--claude-context-goal-guard` | 直接安装 Claude Code goal 上下文收尾提醒,不再询问 |
|
|
39
|
+
| `--no-claude-context-goal-guard` | 跳过 Claude Code goal guard,不再询问;已有 guard 保留 |
|
|
40
|
+
| `--claude-context-goal-guard-threshold=N` | Claude Code guard 的触发阈值百分比(1–99 整数,默认 70),同时表示安装;重装不给则沿用已装的值 |
|
|
35
41
|
| `--providers=a,b` | 指定 impeccable 装进哪些 harness 目录(如 `--providers=.claude,.cursor`);默认按项目里已有的目录判断,一个都没有时只装 `.claude` |
|
|
36
42
|
| `--force` | 强制重装,并允许覆盖非法 JSON 的 hook 配置(先存 `.bak`);不覆盖已有 `AGENTS.md` |
|
|
37
43
|
|
|
@@ -41,6 +47,20 @@ npx @xgent-ai/skills install
|
|
|
41
47
|
|
|
42
48
|
非交互环境默认跳过,可用 `--xgent-init` 显式创建,或用 `--no-xgent-init` 跳过询问。文件位于仓根,不受 `--providers` 影响。模板没有待填占位,通用编码准则支持不同 coding agent;Portal App 的 onebox 联调与有前端改动时的真实浏览器验收分别按适用条件执行。
|
|
43
49
|
|
|
50
|
+
`install` 还会询问是否安装 Codex goal 上下文收尾提醒,输入 `y` 或 `yes` 安装,回车和非交互环境默认跳过。此选项不受 `--providers` 或 `--no-impeccable` 影响。确认后先检查含 SQLite 标准库的 Python 3.9+,复制脚本到 `.codex/hooks/context-goal-guard.py`,合并 `.codex/hooks.json` 的同步 `PreToolUse` / `Stop` handler(3 秒超时),保留其他 hooks 与配置;重复安装只更新 guard,不重复添加。
|
|
51
|
+
|
|
52
|
+
安装后会显示用户必须完成的步骤:从目标项目启动 Codex、信任项目 `.codex/` 层,在 `/hooks` 中审阅、信任并启用两个 handler,对**当前 goal** 明确超过阈值(默认 65%)时先保存进度和交接、再暂停的策略。安装输出提供可复制的指令;安装和信任本身不构成暂停授权。恢复 goal 或新开对话后需重新明确策略。当前仅验证 Codex 0.160.0、macOS arm64,完整限制与停用方法见 [启用说明](docs/codex-context-goal-guard.md)。
|
|
53
|
+
|
|
54
|
+
Codex guard 已接入源码与 npm 打包清单,尚未发布到 npm;发布后可用 `npx @xgent-ai/skills install --context-goal-guard`。当前可从本仓库源码为目标项目安装:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
node bin/xgent-skills.js install /absolute/path/to/project --context-goal-guard
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`install` 随后询问是否安装 Claude Code goal 上下文收尾提醒,同样输入 `y` 或 `yes` 安装,回车和非交互环境默认跳过,不受 `--providers` 或 `--no-impeccable` 影响。确认后复制脚本到 `.claude/hooks/context-goal-guard.js`,在个人的 `.claude/settings.local.json` 合并 `PostToolBatch` / `Stop` 两个 handler(5 秒超时),保留其他 hooks 与设置;重复安装只更新 guard。guard 只读当前会话记录:本会话 `/goal` 仍 active 且估算上下文严格超过阈值(默认 70%)时提醒 agent 按既有约定更新进度并交接,回合结束时由 Stop hook 结束回合,Claude Code 原生把 goal 置为 Goal paused,再由你在同一项目目录新开对话或 `/clear` 后重新设置 goal。hook 不写任何文件,也不清除或转移 goal。
|
|
61
|
+
|
|
62
|
+
安装后需重启会话、信任工作区,并在 `/hooks` 中确认两个 handler 来源为 local settings。当前仅验证 Claude Code 2.1.288 交互式 CLI、macOS arm64 与 Opus 5.5 / Sonnet 5.5 / Haiku 4.5,完整限制、接续与停用方法见 [启用说明](docs/claude-context-goal-guard.md)。从源码安装:`node bin/xgent-skills.js install /absolute/path/to/project --claude-context-goal-guard`。
|
|
63
|
+
|
|
44
64
|
Portal 的产品和设计规范仍保留在 [PRODUCT 模板](skills/xgent-init/references/external-app-PRODUCT.template.md) 与 [DESIGN 模板](skills/xgent-init/references/external-app-DESIGN.template.md) 中。需要生成项目上下文时,显式安装并在 Agent 对话中使用 [xgent-init](skills/xgent-init/SKILL.md),它读取清单与代码事实,补齐 PRODUCT.md / DESIGN.md (无前端的 service 型不新生成 DESIGN.md),已有文件保留:
|
|
45
65
|
|
|
46
66
|
```bash
|
|
@@ -91,6 +111,8 @@ node scripts/publish-vendor-r2.mjs # engine 传 R2,下载地址回写 VERSION
|
|
|
91
111
|
| [apply-code-review](skills/apply-code-review/SKILL.md) | 核实代码评审意见,实施合理修复、验证结果并回写实现记录与必要进度 |
|
|
92
112
|
| [apply-doc-review](skills/apply-doc-review/SKILL.md) | 根据 PRD 或开发计划评审报告修订原文,复核关联内容并记录逐条处置结果 |
|
|
93
113
|
|
|
114
|
+
把上述 skill 串成完整交付流水线(想法 → PRD → 分期 → 每期开发与并行测试 → 交付)的方法见 [AI Native SDLC](docs/ai-native-sdlc.md),含各环节产物路径约定与测试线待补环节的现状核对。
|
|
115
|
+
|
|
94
116
|
### 开发、调试与质量
|
|
95
117
|
|
|
96
118
|
| Skill | 说明 |
|
package/bin/xgent-skills.js
CHANGED
|
@@ -19,6 +19,12 @@ const BUNDLE_DIR = path.join(VENDOR_DIR, 'bundle');
|
|
|
19
19
|
const BLOBS_DIR = path.join(BUNDLE_DIR, 'blobs');
|
|
20
20
|
const MANIFEST_FILE = path.join(BUNDLE_DIR, 'manifest.json');
|
|
21
21
|
const AGENTS_TEMPLATE = path.join(PKG_ROOT, 'skills', 'xgent-init', 'references', 'external-app-AGENTS.template.md');
|
|
22
|
+
const CONTEXT_GOAL_GUARD_SRC = path.join(PKG_ROOT, 'hooks', 'codex', 'context-goal-guard.py');
|
|
23
|
+
// 不放进 .claude/hooks:那里的文件会被无条件复制到目标项目,绕过用户选择。
|
|
24
|
+
const CLAUDE_CONTEXT_GOAL_GUARD_SRC = path.join(PKG_ROOT, 'hooks', 'claude', 'context-goal-guard.js');
|
|
25
|
+
// 与两份脚本内置的默认阈值一致;安装器总是把阈值写进 handler 命令,在 /hooks 里可见。
|
|
26
|
+
const CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD = 65;
|
|
27
|
+
const CLAUDE_CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD = 70;
|
|
22
28
|
|
|
23
29
|
// 写入目标项目 .claude/settings.json 的配置,按顶层 key 合并:
|
|
24
30
|
// 这里列出的 key 以本包为准覆盖,其余已有配置保持不动。
|
|
@@ -503,23 +509,166 @@ function installImpeccable(targetDir, options) {
|
|
|
503
509
|
|
|
504
510
|
// ─── install ────────────────────────────────────────────────────────────────
|
|
505
511
|
|
|
506
|
-
async function
|
|
512
|
+
async function confirm(question, skipMessage, answers) {
|
|
507
513
|
if (!process.stdin.isTTY) {
|
|
508
|
-
console.log(
|
|
514
|
+
console.log(skipMessage);
|
|
509
515
|
return false;
|
|
510
516
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
517
|
+
process.stdout.write(question);
|
|
518
|
+
const { value = '' } = await answers.next();
|
|
519
|
+
return /^(y|yes)$/i.test(value.trim());
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 重装没给阈值 flag 时沿用旧 handler 里的值。
|
|
523
|
+
function guardThreshold(command) {
|
|
524
|
+
return Number(/ --threshold ([1-9][0-9]?)$/.exec(command)?.[1]) || undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function installContextGoalGuard(targetDir, force, threshold) {
|
|
528
|
+
const python = spawnSync('python3', ['-c', 'import sys, sqlite3; sys.exit(0 if sys.version_info >= (3, 9) else 1)'],
|
|
529
|
+
{ encoding: 'utf8', timeout: 5000 });
|
|
530
|
+
if (python.status !== 0) {
|
|
531
|
+
throw new Error('Codex goal guard 需要含 sqlite3 的 Python 3.9 或更高版本。请先安装并用 python3 --version 核对,确保 Codex 运行环境也能找到 python3,再重试;或加 --no-context-goal-guard 跳过。');
|
|
532
|
+
}
|
|
533
|
+
const manifestPath = path.join(targetDir, '.codex', 'hooks.json');
|
|
534
|
+
let existing = {};
|
|
535
|
+
let backup = false;
|
|
536
|
+
if (fs.existsSync(manifestPath)) {
|
|
537
|
+
try {
|
|
538
|
+
existing = readJson(manifestPath);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
541
|
+
if (!force) throw new Error(`${manifestPath} 不是合法 JSON,未修改;请先手动修复,或加 --force 备份为 .bak 后覆盖。`);
|
|
542
|
+
backup = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// 合法 JSON 的未知结构不能当成空配置,否则会丢失用户的 hooks。
|
|
546
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)
|
|
547
|
+
|| (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== 'object' || Array.isArray(existing.hooks)))
|
|
548
|
+
|| Object.values(existing.hooks || {}).some((entries) => !Array.isArray(entries))) {
|
|
549
|
+
throw new Error(`${manifestPath} 的 hooks 结构不受支持,未修改;请先手动修复为事件数组。`);
|
|
550
|
+
}
|
|
551
|
+
const scriptPath = path.join(fs.realpathSync(targetDir), '.codex', 'hooks', 'context-goal-guard.py');
|
|
552
|
+
let previous;
|
|
553
|
+
const hooks = { ...existing.hooks };
|
|
554
|
+
for (const event of ['PreToolUse', 'Stop']) {
|
|
555
|
+
const kept = [];
|
|
556
|
+
for (const entry of hooks[event] || []) {
|
|
557
|
+
if (!Array.isArray(entry?.hooks)) {
|
|
558
|
+
kept.push(entry);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const handlers = entry.hooks.filter((handler) => {
|
|
562
|
+
const guard = handler?.type === 'command' && typeof handler.command === 'string'
|
|
563
|
+
&& /\.codex\/hooks\/context-goal-guard\.py(?:['"\s]|$)/.test(handler.command);
|
|
564
|
+
if (guard) previous ??= guardThreshold(handler.command);
|
|
565
|
+
return !guard;
|
|
566
|
+
});
|
|
567
|
+
if (handlers.length === entry.hooks.length) kept.push(entry);
|
|
568
|
+
else if (handlers.length > 0) kept.push({ ...entry, hooks: handlers });
|
|
569
|
+
}
|
|
570
|
+
hooks[event] = kept;
|
|
571
|
+
}
|
|
572
|
+
const limit = threshold ?? previous ?? CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD;
|
|
573
|
+
// POSIX 单引号同时保护空格、美元符号、反引号及项目路径中的单引号。
|
|
574
|
+
const command = `python3 '${scriptPath.replace(/'/g, "'\\''")}' --threshold ${limit}`;
|
|
575
|
+
for (const event of ['PreToolUse', 'Stop']) hooks[event].push({ hooks: [{ type: 'command', command, timeout: 3 }] });
|
|
576
|
+
const script = fs.readFileSync(CONTEXT_GOAL_GUARD_SRC);
|
|
577
|
+
const manifest = Buffer.from(JSON.stringify({ ...existing, hooks }, null, 2) + '\n');
|
|
578
|
+
if (backup) fs.copyFileSync(manifestPath, `${manifestPath}.bak`);
|
|
579
|
+
console.log(` ${writeFileIfChanged(scriptPath, script) ? '写入' : '未变'} .codex/hooks/context-goal-guard.py`);
|
|
580
|
+
console.log(` ${writeFileIfChanged(manifestPath, manifest) ? '写入' : '未变'} .codex/hooks.json (context goal guard)`);
|
|
581
|
+
return limit;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function printContextGoalGuardSteps(threshold) {
|
|
585
|
+
console.log(`
|
|
586
|
+
Codex goal guard 已部署,请完成以下操作后使用:
|
|
587
|
+
1. 在目标项目运行 codex --version;目前仅验证 Codex 0.160.0、macOS arm64。
|
|
588
|
+
其他版本的会话格式不自动兼容;请先在隔离项目验证。
|
|
589
|
+
2. 从目标项目启动 Codex,审阅并信任项目 .codex/ 层。
|
|
590
|
+
在 Codex 对话中输入 /hooks,找到本项目 .codex/hooks.json 的
|
|
591
|
+
PreToolUse 和 Stop 两个 context-goal-guard.py handler,分别审阅、信任并启用。
|
|
592
|
+
核对两者来源和状态,确认其他 hooks 仍保留;配置或脚本更新后重新审阅信任。
|
|
593
|
+
3. 安装与信任不构成暂停授权。请在当前 goal 的用户指令中明确(可复制):
|
|
594
|
+
对当前 goal,如果本地估算上下文使用率严格超过 ${threshold}%,请先安全收尾,
|
|
595
|
+
按已有任务约定保存进度和交接说明,再使用原生 goal 工具将其设为 paused
|
|
596
|
+
并核对,无需二次确认。未完成工作不要标为 complete。请提醒我自己新开对话继续。
|
|
597
|
+
4. 先用测试 goal 验证提醒与真实 paused 状态。/goal resume 撤销先前暂停请求,
|
|
598
|
+
需重新明确策略;新开对话也需手动设置新 goal 并重新明确策略。
|
|
599
|
+
暂停失败时在原对话输入 /goal pause;临时停用可在 /hooks 中禁用这两个 handler。
|
|
600
|
+
提示: Python 3.9+ 必须在 Codex 运行环境可用;没有 active goal 时不会触发。
|
|
601
|
+
`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 与 installContextGoalGuard 形状相似,但文件、事件和匹配式都不同,暂不抽公共函数。
|
|
605
|
+
function installClaudeContextGoalGuard(targetDir, force, threshold) {
|
|
606
|
+
const settingsPath = path.join(targetDir, '.claude', 'settings.local.json');
|
|
607
|
+
let existing = {};
|
|
608
|
+
let backup = false;
|
|
609
|
+
if (fs.existsSync(settingsPath)) {
|
|
610
|
+
try {
|
|
611
|
+
existing = readJson(settingsPath);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
614
|
+
if (!force) throw new Error(`${settingsPath} 不是合法 JSON,未修改;请先手动修复,或加 --force 备份为 .bak 后覆盖。`);
|
|
615
|
+
backup = true;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// 合法 JSON 的未知结构不能当成空配置,否则会丢失用户的设置。
|
|
619
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)
|
|
620
|
+
|| (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== 'object' || Array.isArray(existing.hooks)))
|
|
621
|
+
|| Object.values(existing.hooks || {}).some((entries) => !Array.isArray(entries))) {
|
|
622
|
+
throw new Error(`${settingsPath} 的 hooks 结构不受支持,未修改;请先手动修复为事件数组。`);
|
|
623
|
+
}
|
|
624
|
+
let previous;
|
|
625
|
+
const hooks = { ...existing.hooks };
|
|
626
|
+
for (const event of ['PostToolBatch', 'Stop']) {
|
|
627
|
+
const kept = [];
|
|
628
|
+
for (const entry of hooks[event] || []) {
|
|
629
|
+
if (!Array.isArray(entry?.hooks)) {
|
|
630
|
+
kept.push(entry);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const handlers = entry.hooks.filter((handler) => {
|
|
634
|
+
const guard = handler?.type === 'command' && typeof handler.command === 'string'
|
|
635
|
+
&& /\.claude\/hooks\/context-goal-guard\.js(?:['"\s]|$)/.test(handler.command);
|
|
636
|
+
if (guard) previous ??= guardThreshold(handler.command);
|
|
637
|
+
return !guard;
|
|
638
|
+
});
|
|
639
|
+
if (handlers.length === entry.hooks.length) kept.push(entry);
|
|
640
|
+
else if (handlers.length > 0) kept.push({ ...entry, hooks: handlers });
|
|
641
|
+
}
|
|
642
|
+
hooks[event] = kept;
|
|
643
|
+
}
|
|
644
|
+
const limit = threshold ?? previous ?? CLAUDE_CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD;
|
|
645
|
+
const command = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/context-goal-guard.js" --threshold ${limit}`;
|
|
646
|
+
for (const event of ['PostToolBatch', 'Stop']) hooks[event].push({ hooks: [{ type: 'command', command, timeout: 5 }] });
|
|
647
|
+
const script = fs.readFileSync(CLAUDE_CONTEXT_GOAL_GUARD_SRC);
|
|
648
|
+
const settings = Buffer.from(JSON.stringify({ ...existing, hooks }, null, 2) + '\n');
|
|
649
|
+
if (backup) fs.copyFileSync(settingsPath, `${settingsPath}.bak`);
|
|
650
|
+
console.log(` ${writeFileIfChanged(path.join(targetDir, '.claude', 'hooks', 'context-goal-guard.js'), script) ? '写入' : '未变'} .claude/hooks/context-goal-guard.js`);
|
|
651
|
+
console.log(` ${writeFileIfChanged(settingsPath, settings) ? '写入' : '未变'} .claude/settings.local.json (context goal guard)`);
|
|
652
|
+
return limit;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function printClaudeContextGoalGuardSteps(threshold) {
|
|
656
|
+
console.log(`
|
|
657
|
+
Claude Code goal guard 已部署,请完成以下操作后使用:
|
|
658
|
+
1. 运行 claude --version;目前仅验证 Claude Code 2.1.288 交互式 CLI、macOS arm64,
|
|
659
|
+
以及 claude-opus-5-5、claude-sonnet-5-5(1M)与 claude-haiku-4-5-20251001(200k)。
|
|
660
|
+
可在 statusLine 输入的 context_window.context_window_size 核对窗口,不一致时不要启用。
|
|
661
|
+
2. 重启 Claude Code 会话并信任本工作区;输入 /hooks,确认 PostToolBatch 与 Stop
|
|
662
|
+
各有一个 context-goal-guard.js handler,来源为 .claude/settings.local.json,其他 hooks 仍在。
|
|
663
|
+
3. 先用测试 goal 验证:估算上下文严格超过 ${threshold}% 时 agent 收到收尾提醒;回合结束时
|
|
664
|
+
界面可能先出现「Stop hook error: …」(收尾请求,属正常),随后出现 Goal paused。
|
|
665
|
+
4. 接续:在同一项目目录新开对话或 /clear,提供计划路径或交接摘要,并重新设置 /goal;
|
|
666
|
+
新对话不继承 goal。原会话发消息或 --resume 会恢复 goal 并再次触发暂停,
|
|
667
|
+
不再需要时在原会话 /goal clear。
|
|
668
|
+
5. 停用:从 .claude/settings.local.json 删除这两个 handler 并重启会话;
|
|
669
|
+
不要用 disableAllHooks,它会连 /goal 一起禁用。
|
|
670
|
+
说明见 @xgent-ai/skills 包内 docs/claude-context-goal-guard.md;没有 active goal 时不会触发。
|
|
671
|
+
`);
|
|
523
672
|
}
|
|
524
673
|
|
|
525
674
|
function createAgents(targetDir) {
|
|
@@ -541,12 +690,21 @@ function usage() {
|
|
|
541
690
|
hook,在 .claude/settings.json 中启用对应配置,并装上
|
|
542
691
|
vendor 的 impeccable(skills + hooks,以及按需下载的
|
|
543
692
|
engine 二进制),并询问是否创建通用的 AGENTS.md
|
|
693
|
+
与安装 Codex / Claude Code goal 上下文收尾提醒
|
|
544
694
|
help 显示本帮助
|
|
545
695
|
|
|
546
696
|
install 选项:
|
|
547
697
|
--no-impeccable 跳过 impeccable,仍安装 XGENT 的 hooks 与 settings
|
|
548
698
|
--xgent-init 创建 AGENTS.md (已有则跳过),不再询问
|
|
549
699
|
--no-xgent-init 跳过 AGENTS.md,不再询问
|
|
700
|
+
--context-goal-guard 安装 Codex goal guard,不再询问(需要 Python 3.9+)
|
|
701
|
+
--no-context-goal-guard 跳过 Codex goal guard,不再询问
|
|
702
|
+
--context-goal-guard-threshold=N Codex guard 的阈值百分比(1–99 整数,默认 ${CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD};
|
|
703
|
+
重装时不给则沿用已装的值),同时表示安装
|
|
704
|
+
--claude-context-goal-guard 安装 Claude Code goal guard,不再询问
|
|
705
|
+
--no-claude-context-goal-guard 跳过 Claude Code goal guard,不再询问
|
|
706
|
+
--claude-context-goal-guard-threshold=N Claude Code guard 的阈值百分比(1–99 整数,
|
|
707
|
+
默认 ${CLAUDE_CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD};重装时不给则沿用已装的值),同时表示安装
|
|
550
708
|
--providers=a,b 指定 impeccable 装进哪些 harness 目录(默认按项目里
|
|
551
709
|
已有的目录判断,都没有时装 ${DEFAULT_TARGETS.join(' 和 ')})
|
|
552
710
|
--force 强制重装,并允许覆盖非法 JSON 的 hook 配置(先存 .bak),
|
|
@@ -557,7 +715,8 @@ install 选项:
|
|
|
557
715
|
async function install(args) {
|
|
558
716
|
const flags = args.filter((a) => a.startsWith('--'));
|
|
559
717
|
const dirArg = args.find((a) => !a.startsWith('--'));
|
|
560
|
-
const unknown = flags.filter((f) => !['--no-impeccable', '--xgent-init', '--no-xgent-init', '--force'].includes(f)
|
|
718
|
+
const unknown = flags.filter((f) => !['--no-impeccable', '--xgent-init', '--no-xgent-init', '--context-goal-guard', '--no-context-goal-guard', '--claude-context-goal-guard', '--no-claude-context-goal-guard', '--force'].includes(f)
|
|
719
|
+
&& !['--providers=', '--context-goal-guard-threshold=', '--claude-context-goal-guard-threshold='].some((p) => f.startsWith(p)));
|
|
561
720
|
if (unknown.length > 0) {
|
|
562
721
|
console.error(`错误: 未知选项: ${unknown.join(', ')}\n`);
|
|
563
722
|
usage();
|
|
@@ -567,6 +726,32 @@ async function install(args) {
|
|
|
567
726
|
console.error('错误: --xgent-init 与 --no-xgent-init 不能同时使用');
|
|
568
727
|
process.exit(1);
|
|
569
728
|
}
|
|
729
|
+
if (flags.includes('--context-goal-guard') && flags.includes('--no-context-goal-guard')) {
|
|
730
|
+
console.error('错误: --context-goal-guard 与 --no-context-goal-guard 不能同时使用');
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
if (flags.includes('--claude-context-goal-guard') && flags.includes('--no-claude-context-goal-guard')) {
|
|
734
|
+
console.error('错误: --claude-context-goal-guard 与 --no-claude-context-goal-guard 不能同时使用');
|
|
735
|
+
process.exit(1);
|
|
736
|
+
}
|
|
737
|
+
const thresholds = {};
|
|
738
|
+
for (const [name, skip] of [['--context-goal-guard-threshold', '--no-context-goal-guard'],
|
|
739
|
+
['--claude-context-goal-guard-threshold', '--no-claude-context-goal-guard']]) {
|
|
740
|
+
const flag = flags.find((f) => f.startsWith(`${name}=`));
|
|
741
|
+
if (flag === undefined) continue;
|
|
742
|
+
const value = flag.slice(name.length + 1);
|
|
743
|
+
if (!/^[1-9][0-9]?$/.test(value)) {
|
|
744
|
+
console.error(`错误: ${name} 需要 1–99 的整数百分比,收到 "${value}"`);
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
747
|
+
if (flags.includes(skip)) {
|
|
748
|
+
console.error(`错误: ${name} 与 ${skip} 不能同时使用`);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
thresholds[name] = Number(value);
|
|
752
|
+
}
|
|
753
|
+
const codexThreshold = thresholds['--context-goal-guard-threshold'];
|
|
754
|
+
const claudeThreshold = thresholds['--claude-context-goal-guard-threshold'];
|
|
570
755
|
const options = {
|
|
571
756
|
impeccable: !flags.includes('--no-impeccable'),
|
|
572
757
|
force: flags.includes('--force'),
|
|
@@ -582,7 +767,34 @@ async function install(args) {
|
|
|
582
767
|
options.detected = detectProviders(targetDir);
|
|
583
768
|
if (options.providers) resolveProviders(options.detected, options.providers);
|
|
584
769
|
const agentsExists = fs.existsSync(path.join(targetDir, 'AGENTS.md'));
|
|
585
|
-
const
|
|
770
|
+
const askAgents = !flags.includes('--xgent-init') && !flags.includes('--no-xgent-init') && !agentsExists;
|
|
771
|
+
const askGuard = !flags.includes('--context-goal-guard') && !flags.includes('--no-context-goal-guard')
|
|
772
|
+
&& codexThreshold === undefined;
|
|
773
|
+
const askClaudeGuard = !flags.includes('--claude-context-goal-guard') && !flags.includes('--no-claude-context-goal-guard')
|
|
774
|
+
&& claudeThreshold === undefined;
|
|
775
|
+
// 各询问共享输入迭代器,避免前一问提前读掉后一问的回答。
|
|
776
|
+
const rl = process.stdin.isTTY && (askAgents || askGuard || askClaudeGuard)
|
|
777
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
778
|
+
const answers = rl?.[Symbol.asyncIterator]();
|
|
779
|
+
rl?.once('SIGINT', () => { rl.close(); process.exit(130); });
|
|
780
|
+
let agents;
|
|
781
|
+
let guard;
|
|
782
|
+
let claudeGuard;
|
|
783
|
+
try {
|
|
784
|
+
agents = flags.includes('--xgent-init') || (askAgents && await confirm(
|
|
785
|
+
'是否同时创建通用的 AGENTS.md (含 Portal 联调与前端验收约定)? [y/N] ',
|
|
786
|
+
' 跳过 AGENTS.md:非交互环境,可加 --xgent-init 创建', answers));
|
|
787
|
+
guard = flags.includes('--context-goal-guard') || codexThreshold !== undefined || (askGuard && await confirm(
|
|
788
|
+
`是否安装 Codex goal 上下文收尾提醒 (默认超过 ${CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD}% 时提醒收尾,需 Python 3.9+ 与 Codex 内手动信任)? [y/N] `,
|
|
789
|
+
' 跳过 Codex goal guard:非交互环境,可加 --context-goal-guard 安装', answers));
|
|
790
|
+
claudeGuard = flags.includes('--claude-context-goal-guard') || claudeThreshold !== undefined || (askClaudeGuard && await confirm(
|
|
791
|
+
`是否安装 Claude Code goal 上下文收尾提醒 (默认超过 ${CLAUDE_CONTEXT_GOAL_GUARD_DEFAULT_THRESHOLD}% 时提醒收尾并在回合结束时暂停 goal,需重启会话并信任工作区)? [y/N] `,
|
|
792
|
+
' 跳过 Claude Code goal guard:非交互环境,可加 --claude-context-goal-guard 安装', answers));
|
|
793
|
+
} finally {
|
|
794
|
+
rl?.close();
|
|
795
|
+
}
|
|
796
|
+
const codexLimit = guard && installContextGoalGuard(targetDir, options.force, codexThreshold);
|
|
797
|
+
const claudeLimit = claudeGuard && installClaudeContextGoalGuard(targetDir, options.force, claudeThreshold);
|
|
586
798
|
|
|
587
799
|
const hooksDestDir = path.join(targetDir, '.claude', 'hooks');
|
|
588
800
|
fs.mkdirSync(hooksDestDir, { recursive: true });
|
|
@@ -625,6 +837,8 @@ async function install(args) {
|
|
|
625
837
|
|
|
626
838
|
if (agents) createAgents(targetDir);
|
|
627
839
|
else if (agentsExists && !flags.includes('--no-xgent-init')) console.log(' 跳过 AGENTS.md (已存在,保留原文)');
|
|
840
|
+
if (codexLimit) printContextGoalGuardSteps(codexLimit);
|
|
841
|
+
if (claudeLimit) printClaudeContextGoalGuardSteps(claudeLimit);
|
|
628
842
|
console.log(`完成: ${targetDir}`);
|
|
629
843
|
}
|
|
630
844
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Claude Code goal 上下文收尾提醒
|
|
2
|
+
|
|
3
|
+
当前会话的 `/goal` 仍 active,且本地估算上下文使用率**严格超过阈值**(默认 70%,可配置)时,本 hook 提醒 agent 收尾:停止开始新的实质任务,按任务已有约定更新进度并交接。回合结束时由 Stop hook 结束回合,Claude Code 原生把 goal 置为「Goal paused」,再由你自己新开对话(或 `/clear`)接续。恰好等于阈值不触发。hook 只读当前会话记录,不写任何文件,也不清除、转移或恢复 goal。
|
|
4
|
+
|
|
5
|
+
## 兼容范围与限制
|
|
6
|
+
|
|
7
|
+
- 已验证:Claude Code **2.1.288** 交互式 CLI、macOS arm64、Node v25.2.1(安装器要求 Node ≥ 18)。完整验收见 [M1 记录](plan/claude-context-goal-guard.records/M1.md)、[M2 记录](plan/claude-context-goal-guard.records/M2.md)、[M3 记录](plan/claude-context-goal-guard.records/M3.md)、[M4 记录](plan/claude-context-goal-guard.records/M4.md)。新版本不自动视为兼容。
|
|
8
|
+
- 未验证:`claude -p` 非交互模式、桌面应用、Remote Control、云端会话。
|
|
9
|
+
- 只保护主会话;子 agent 内的事件直接跳过。
|
|
10
|
+
- 上下文窗口:hook 输入和会话记录都不含窗口大小,脚本只认下表中验证过的模型。其他模型,或设置了 `CLAUDE_CODE_DISABLE_1M_CONTEXT`、`DISABLE_COMPACT`、`CLAUDE_CODE_MAX_CONTEXT_TOKENS` 任一变量时,报告「检测不可用」,不触发。
|
|
11
|
+
|
|
12
|
+
| 模型 id(会话记录中的 `message.model`) | 窗口 |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `claude-opus-5-5` | 1,000,000(是否选 `[1m]` 相同) |
|
|
15
|
+
| `claude-sonnet-5-5` | 1,000,000(是否选 `[1m]` 相同) |
|
|
16
|
+
| `claude-haiku-4-5-20251001` | 200,000 |
|
|
17
|
+
|
|
18
|
+
窗口还会因账号与服务端能力而不同,上表只在验证时用的 Claude Max 账号上核对过。启用前用 statusLine 自查:statusLine 命令的 stdin JSON 里 `model.id` 和 `context_window.context_window_size` 应与上表一致,不一致时不要启用。
|
|
19
|
+
|
|
20
|
+
### 用量口径
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
used = input_tokens + cache_creation_input_tokens + cache_read_input_tokens # 取最新主链 assistant 记录;有 iterations 时取最后一项
|
|
24
|
+
trigger = used * 100 > window * threshold # threshold 默认 70,见「阈值配置」
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
与 statusLine 的 `used_percentage` 同口径,不计 output。会话记录异步写入,可能落后一条消息。自动压缩后,出现新统计前不判定。
|
|
28
|
+
|
|
29
|
+
Claude Code 界面右下角的「NN% context used」按扣除输出预留后的有效窗口计算,读数比本 guard 高。例如 Haiku 4.5 实测 155,100 token 时,界面显示 86%(≈ 155,100 / 180,000),guard 计为 78%。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
### 使用 install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
# 从本仓库源码安装(发布到 npm 后可用 npx @xgent-ai/skills install)
|
|
37
|
+
node bin/xgent-skills.js install /absolute/path/to/project
|
|
38
|
+
# 显式安装,不再询问
|
|
39
|
+
node bin/xgent-skills.js install /absolute/path/to/project --claude-context-goal-guard
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
交互安装会在 Codex guard 之后询问是否安装 Claude Code goal 上下文收尾提醒,输入 `y` 或 `yes` 确认;回车和非交互环境默认跳过。`--claude-context-goal-guard` 显式安装,`--no-claude-context-goal-guard` 跳过询问并保留已有 guard,两者不能同时使用。这个选项与 `--providers`、`--no-impeccable`、`--xgent-init` 及 Codex guard 的选项互不影响。
|
|
43
|
+
|
|
44
|
+
安装器会:
|
|
45
|
+
|
|
46
|
+
1. 复制脚本到目标项目 `.claude/hooks/context-goal-guard.js`;
|
|
47
|
+
2. 把 `PostToolBatch` 与 `Stop` 两个 handler 合并进个人的 `.claude/settings.local.json`(不进共享的 `settings.json`),command 为 `node "$CLAUDE_PROJECT_DIR/.claude/hooks/context-goal-guard.js"`,超时 5 秒;
|
|
48
|
+
3. 保留其他事件、handler、matcher 和顶层字段,重复安装只更新 guard。非法 JSON 默认报错,`--force` 先备份为 `.bak` 再覆盖;合法 JSON 但结构不认识时报错,需要手动修复。
|
|
49
|
+
|
|
50
|
+
### 阈值配置
|
|
51
|
+
|
|
52
|
+
阈值写在 handler 命令末尾:`--threshold N`,N 为 1–99 的整数百分比;不带参数时为 70。与 Codex 版(默认 65)各自独立。
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
# 安装时指定(同时表示安装,不再询问)
|
|
56
|
+
node bin/xgent-skills.js install /absolute/path/to/project --claude-context-goal-guard-threshold=75
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
安装器总是把阈值明确写进两个 handler 的 command,在 `/hooks` 里看得到。之后重装时不给这个 flag,就沿用已装的值。也可以直接改 `.claude/settings.local.json` 里两条 command 末尾的数字,两条要一致。参数无效时(如 `0`、`100`、`6.5`)guard 不猜测数值,按「检测不可用」处理。
|
|
60
|
+
|
|
61
|
+
阈值不宜太高:收尾本身还要消耗上下文,而 Claude Code 在接近有效窗口上限时会自动压缩(见上面的界面读数说明)。建议不超过 75%。
|
|
62
|
+
|
|
63
|
+
### 手动部署
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
mkdir -p "/absolute/path/to/project/.claude/hooks"
|
|
67
|
+
cp hooks/claude/context-goal-guard.js "/absolute/path/to/project/.claude/hooks/context-goal-guard.js"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
把 [设置示例](../hooks/claude/settings.example.json) 里的两组 handler(默认 `--threshold 70`,按需修改)**追加**到目标项目 `.claude/settings.local.json` 已有的 `PostToolBatch`、`Stop` 数组,不要用示例覆盖整个文件。Claude Code 的 hook 运行环境里必须能找到 `node`。
|
|
71
|
+
|
|
72
|
+
## 信任与生效确认
|
|
73
|
+
|
|
74
|
+
1. 运行 `claude --version`,确认是上面验证过的版本。
|
|
75
|
+
2. 从目标项目启动 Claude Code,信任该工作区。工作区未被信任时,设置里的 hook 和 `/goal` 都不可用。
|
|
76
|
+
3. 输入 `/hooks`,确认 `PostToolBatch` 和 `Stop` 下各有一条 `[Local] node "$CLAUDE_PROJECT_DIR/.claude/hooks/context-g…`,其他 hooks 仍在。
|
|
77
|
+
4. 先用测试 goal 走一遍:越过阈值后应看到提醒,回合结束后出现「Goal paused · a hook ended the turn」。
|
|
78
|
+
|
|
79
|
+
## goal 条件怎么写
|
|
80
|
+
|
|
81
|
+
guard 不管进度写到哪里,提醒只要求 agent 按任务**已有约定**更新进度。最好让 goal 条件指向一份带进度区的计划,例如:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/goal 按 docs/plan/xxx.md 开发:全部里程碑完成并回写进度
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
没有计划文件时,agent 会在最终回复里给出交接摘要,不会新建文件。
|
|
88
|
+
|
|
89
|
+
## 触发后会看到什么
|
|
90
|
+
|
|
91
|
+
1. 越线后的每个工具批次,agent 都会收到同一条收尾提醒:停止新工作,把手上的工作处理到可交接,更新进度,停止后台任务,写明「因上下文收尾而暂停,工作未完成」。
|
|
92
|
+
2. agent 第一次结束回合时,guard 可能先请求补做收尾。界面上显示为「Stop hook error: 上下文估算使用率 NN% …」,这是正常的继续请求,不是故障。
|
|
93
|
+
3. 下一次结束回合时,guard 结束回合,界面显示 guard 的说明和「Goal paused · a hook ended the turn · send a message to continue」。goal 评估器会照常判定「未达成」,但不会再自动续跑。
|
|
94
|
+
|
|
95
|
+
提醒没有强制力。在 goal 的自动续轮里越线时,agent 可能只收到一次提醒、甚至一次都没收到就被暂停。交接不完整时,在原会话发一句「按上下文收尾提醒补交接」,回合结束后会再次暂停。
|
|
96
|
+
|
|
97
|
+
## 接续
|
|
98
|
+
|
|
99
|
+
在同一项目目录新开对话,或在原会话输入 `/clear`。告诉 agent 计划路径(或粘贴交接摘要),再重新设置 `/goal`。新对话和 `/clear` 后都**不继承** goal:不重新设置,guard 就不会触发。
|
|
100
|
+
|
|
101
|
+
原会话的 goal 只是暂停:在原会话里发任何消息都会恢复 goal,guard 随即再次收口;`claude --resume` 也会恢复 goal(不会自动运行)。不再需要时,在原会话 `/goal clear`。
|
|
102
|
+
|
|
103
|
+
### 后台任务
|
|
104
|
+
|
|
105
|
+
暂停时如果还有后台命令或子 agent 在跑,它们完成时的通知(以及 goal 的 idle check-in)会唤醒会话。guard 会先请求一次收尾,在请求里列出仍在运行的任务,再结束回合;进度不会前进,但每次唤醒多花两次简短的模型调用。这种被唤醒后再结束的回合,界面可能显示「Goal not yet met… continuing」,状态栏仍是「/goal active」,没有「Goal paused」提示;实测此后约 7 分钟内没有自动回合。
|
|
106
|
+
|
|
107
|
+
## 停用
|
|
108
|
+
|
|
109
|
+
从 `.claude/settings.local.json` 删除 command 含 `context-goal-guard.js` 的两个 handler,保留其他 hooks,再删除 `.claude/hooks/context-goal-guard.js`。实测会话运行中修改后,`/hooks` 数秒内就不再列出它们;保险起见仍建议重启会话。已暂停的 goal 不受影响。
|
|
110
|
+
|
|
111
|
+
不要用 `disableAllHooks`:它会连 `/goal` 一起禁用。
|
|
112
|
+
|
|
113
|
+
## 排查
|
|
114
|
+
|
|
115
|
+
guard 只在 **Stop** 时显示告警,不会在每个工具批次刷屏。告警格式为「context goal guard 检测不可用:<原因>」:
|
|
116
|
+
|
|
117
|
+
| 原因 | 处理 |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| 模型 `X` 的上下文窗口未经验证 | 换用上表中的模型,或停用 guard;补表需要先在同环境核对 statusLine 的窗口 |
|
|
120
|
+
| 设置了改变上下文窗口的环境变量 `X` | 取消该变量,或停用 guard |
|
|
121
|
+
| 无法读取会话记录 / hook 输入缺少会话记录路径 | 核对 Claude Code 版本;持续出现时停用 guard |
|
|
122
|
+
| 用量统计格式未知 | Claude Code 的会话记录格式变了,停用 guard 并等待重新验证 |
|
|
123
|
+
| 阈值参数无效 | 把两条 command 末尾改为 `--threshold` 加 1–99 的整数,或重新运行安装器 |
|
|
124
|
+
|
|
125
|
+
检测不可用时,guard 不提醒、不结束回合,也不显示 0%。
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Codex goal 上下文收尾提醒
|
|
2
|
+
|
|
3
|
+
当本地估算上下文使用率**严格超过阈值**(默认 65%,可配置)且当前根线程的 goal 为 `active` 时,本 hook 提醒 agent 收尾:更新已有任务记录、交接未完成项、按用户明确的暂停策略调用原生 goal 工具,再提醒用户自行新开对话。恰好等于阈值不触发。hook 只读取本地数据,不写进度、修改 goal 或创建对话。
|
|
4
|
+
|
|
5
|
+
## 兼容范围与限制
|
|
6
|
+
|
|
7
|
+
首期格式限定为 Codex CLI / App Server **0.160.0**。已验证环境为 macOS arm64、Python 3.13.7;运行服务标识及完整验收见 [M1 记录](plan/codex-context-goal-guard.records/M1.md)与 [M3 记录](plan/codex-context-goal-guard.records/M3.md)。Python 至少需要 3.9。后续 Codex 版本不自动视为兼容,未知格式会报告检测不可用。
|
|
8
|
+
|
|
9
|
+
估算值来自当前会话最新完整 `token_count` 的 `last_token_usage.total_tokens / model_context_window`,可能滞后于 UI。累计 tokens、cached tokens 和 reasoning tokens 不重复加入。压缩或模型/窗口改变后,没有新统计时不沿用旧值。子 agent 跳过;fork 只读取自己的 goal。读取位置由真实 hook 输入和 `CODEX_HOME` 决定,未设置时使用 `~/.codex`。
|
|
10
|
+
|
|
11
|
+
检查发生在同步 `PreToolUse` 和 `Stop` 边界;已经调度的调用可能完成,部分工具路径没有 tool hook。提醒依赖 agent 执行,不能强制暂停。首次 Stop 可以请求补做收尾,第二次仍未暂停时只告警;此告警不能阻止 goal 自动续跑。上述事件、信任及输出语义见 [官方 Hooks 文档](https://learn.chatgpt.com/docs/hooks)。
|
|
12
|
+
|
|
13
|
+
## 获取与部署
|
|
14
|
+
|
|
15
|
+
### 使用 install
|
|
16
|
+
|
|
17
|
+
功能已接入 `install` 源码和 npm 打包清单,尚未发布到 npm。当前从本仓库根目录运行:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
node bin/xgent-skills.js install /absolute/path/to/project
|
|
21
|
+
# 显式选择安装,不再询问此选项
|
|
22
|
+
node bin/xgent-skills.js install /absolute/path/to/project --context-goal-guard
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
发布包含此功能的新版本后,可用 `npx @xgent-ai/skills install`。交互安装会询问是否安装 Codex goal 上下文收尾提醒,输入 `y` 或 `yes` 确认;回车或非交互环境默认跳过。`--context-goal-guard` 显式安装,`--no-context-goal-guard` 跳过询问并保留已有 guard;两者不能同时使用。此选择独立于 `--providers`、`--no-impeccable` 和 `--xgent-init`。
|
|
26
|
+
|
|
27
|
+
安装器检查 `python3` 为 3.9+ 且包含 SQLite 标准库,然后复制脚本到目标项目 `.codex/hooks/context-goal-guard.py`,将两个同步 handler 合并到 `.codex/hooks.json`,使用项目规范绝对路径和 3 秒超时。其他事件、handler、matcher 和顶层字段保留;重复安装更新 guard,不重复添加。非法 JSON 默认报错,`--force` 可先备份为 `.bak` 再覆盖;合法 JSON 的未知结构需手动修复。
|
|
28
|
+
|
|
29
|
+
Python 缺失或版本不足时,按安装器提示先安装 Python 并运行 `python3 --version` 核对,再重试;也可用 `--no-context-goal-guard` 完成其他安装。Codex 运行环境也必须能解析 `python3`,否则按下方手动配置说明改为解释器绝对路径。
|
|
30
|
+
|
|
31
|
+
安装结束会打印下方「信任与生效确认」的操作步骤及可复制的当前 goal 暂停策略。**脚本已部署不等于已生效**:用户仍需在 Codex 内信任项目、审阅并信任启用两个 handler、明确当前 goal 的暂停策略,并用测试 goal 验证真实暂停。安装器不代替用户完成这些步骤。
|
|
32
|
+
|
|
33
|
+
### 阈值配置
|
|
34
|
+
|
|
35
|
+
阈值写在 handler 命令末尾:`--threshold N`,N 为 1–99 的整数百分比;不带参数时为 65。与 Claude Code 版(默认 70)各自独立。
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node bin/xgent-skills.js install /absolute/path/to/project --context-goal-guard-threshold=60
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--context-goal-guard-threshold=N` 同时表示安装,不再询问;与 `--no-context-goal-guard` 不能同时使用。安装器总是把阈值写进两个 handler 的 command,重装时不给这个 flag 就沿用已装的值;安装输出里可复制的暂停策略也使用实际阈值。改动 command 后需要在 `/hooks` 中重新审阅、信任。参数无效时 guard 报告检测不可用,不猜测数值。
|
|
42
|
+
|
|
43
|
+
### 手动部署
|
|
44
|
+
|
|
45
|
+
也可单独复制仓库文件。首期手动部署的精确本地验证 revision 记在 [M3 记录](plan/codex-context-goal-guard.records/M3.md):在可访问该本地仓库的机器 clone 并 checkout 该 revision(该 revision 不含后续 install 集成)。远程发布后,才可从 `https://github.com/XGENT-ai/skills.git` checkout 对应已发布 revision;不要将尚未发布的 SHA 当作远程可获取版本。
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
git clone /absolute/path/to/skills /tmp/skills-guard-source
|
|
49
|
+
git -C /tmp/skills-guard-source checkout <M3记录中的本地验证revision>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
先核对 `codex --version` 和 `python3 --version`。以下路径均替换为实际目录;从项目根目录取得规范绝对路径(例如 `pwd -P`),避免 `/var` 与 `/private/var` 等别名导致信任来源不一致。
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
mkdir -p "/absolute/path/to/My Project/.codex/hooks"
|
|
56
|
+
cp "/tmp/skills-guard-source/hooks/codex/context-goal-guard.py" "/absolute/path/to/My Project/.codex/hooks/context-goal-guard.py"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
打开目标项目 `.codex/hooks.json`,将 [配置示例](../hooks/codex/hooks.example.json)中的两组 handler **追加**到已有 `PreToolUse` 和 `Stop` 数组;没有文件或事件时才新建对应对象。保留其他事件、handler、matcher 和顶层字段。不要用整个示例覆盖已有 manifest。
|
|
60
|
+
|
|
61
|
+
将两个 command 中的 `/path/to/My Project` 替换为项目规范绝对路径,保留 JSON 内转义的双引号:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{"type": "command", "command": "python3 \"/absolute/path/to/My Project/.codex/hooks/context-goal-guard.py\" --threshold 65", "timeout": 3}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
若 Codex 环境无法解析 `python3`,将其换为已核对的 Python 3 可执行文件绝对路径,也保留必要引号。普通含空格路径已列入验收;包含 shell 元字符的路径应另行正确引用并验证,不直接粘贴到 command。同步超时为 3 秒。
|
|
68
|
+
|
|
69
|
+
## 信任与生效确认
|
|
70
|
+
|
|
71
|
+
1. 从目标项目启动 Codex,审阅并信任项目 `.codex/` 层。
|
|
72
|
+
2. 打开 `/hooks`,找到来源为本项目 `.codex/hooks.json` 的两个 guard handler;审阅命令、路径、事件与超时,分别信任并启用。
|
|
73
|
+
3. 再次确认两个 handler 的来源、启用和信任状态;确认其他 hooks 仍在列表中。未信任时会被跳过,安静无告警不能证明已生效。
|
|
74
|
+
4. 修改 hook 定义或 command 路径后,重新在 `/hooks` 审阅信任。信任绑定定义 hash,不伪造 hash。替换脚本内容也需自行重新审阅;不要假定定义 hash 会检测脚本内容变化。
|
|
75
|
+
5. 在隔离项目、测试 goal 中验证提示和真实暂停后,再用于日常任务。可从项目子目录启动,command 的绝对路径仍有效。
|
|
76
|
+
|
|
77
|
+
安装、信任和阅读说明都不构成暂停授权。在**当前 goal** 的用户指令里明确,例如:
|
|
78
|
+
|
|
79
|
+
> 对当前 goal,如果本地估算上下文使用率严格超过 65%(按实际阈值替换),请先安全收尾,按已有任务约定保存进度和交接说明,再使用原生 goal 工具将其设为 paused 并核对,无需二次确认。未完成工作不要标为 complete。请提醒我自己新开对话继续。
|
|
80
|
+
|
|
81
|
+
后续 `/goal resume` 撤销先前暂停请求,需重新明确策略。普通命令测试可使用 `features.code_mode.enabled=true` 和 `features.code_mode.direct_only_tool_namespaces=["functions"]`;code mode 使用默认路径。M1 验证了两者;仅关闭 code mode 不保证普通命令回退。配置字段见 [官方配置参考](https://learn.chatgpt.com/docs/config-file/config-reference)。
|
|
82
|
+
|
|
83
|
+
## 收尾与人工接续
|
|
84
|
+
|
|
85
|
+
成功回复应说明真实 `paused` 状态、已有记录位置、剩余任务和下一步。无既有记录时,agent 在回复中给出交接摘要(完成/未完成、验证/未验证、下一步、仍运行的进程),不强制创建新文件。进度写入失败时,也先在回复保留最小摘要并明确失败。
|
|
86
|
+
|
|
87
|
+
在同一项目目录自行新开对话,提供已有计划路径,或粘贴交接摘要并说明继续。新对话没有继承的 goal;需要用户手动设置 goal、重新明确按阈值暂停的策略后,guard 才对它生效。旧 goal 保持 paused;可保留,或回旧对话主动 `/goal clear`。恢复旧 goal 不清空上下文,不作为腾出上下文的办法。线程生命周期命令见 [官方 Goals 文档](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex)。
|
|
88
|
+
|
|
89
|
+
如果 agent 请求确认、拒绝暂停或暂停失败,仍算尚未暂停;在原对话执行 `/goal pause` 后再接续。不要依据“已提醒”判断暂停成功。
|
|
90
|
+
|
|
91
|
+
## 排查与停用
|
|
92
|
+
|
|
93
|
+
| 表现 | 检查与处理 |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `/hooks` 没有 guard 或显示待审/禁用 | 核对项目层信任、来源、manifest JSON、绝对 command 路径;重新审阅信任并启用 |
|
|
96
|
+
| “检测不可用” | 核对 Codex 版本、进程实际 `CODEX_HOME`、会话和 `goals_1.sqlite` 的存在与读权限;不手工补库或改状态。格式/schema/锁异常持续时在 `/hooks` 停用 guard |
|
|
97
|
+
| hook 超时/失败提示 | 本次检测失效,工具可能继续。检查 Python、文件读取与存储延迟,在隔离环境重验后再调整 3 秒上限 |
|
|
98
|
+
| 超过阈值但没有提示 | 核对当前根线程是否有 active goal、新统计是否已记录及工具事件覆盖;不能用 UI 百分比直接代替本地统计 |
|
|
99
|
+
| Stop 后仍 active 或持续续跑 | 在原对话 `/goal pause`;检查暂停授权和其他 Stop hooks。其他 hooks 的继续/停止要求须组合实测,不能宣称所有组合兼容 |
|
|
100
|
+
|
|
101
|
+
临时停用:在 `/hooks` 中仅禁用本 guard 的两个 handler。永久移除:从两个事件数组中只删除 command 指向 `context-goal-guard.py` 的 handler,保留其他 hooks;确认不再引用后可删除该脚本副本。任务记录保留,paused goal 不自动恢复,不需要数据库迁移或回填。
|
|
102
|
+
|
|
103
|
+
## 本地验证
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
python3 -m unittest discover -s test/codex -p 'test_*.py'
|
|
107
|
+
python3 -m py_compile hooks/codex/context-goal-guard.py
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
行为测试通过真实脚本入口、临时 JSONL 和只读 SQLite 验证阈值、身份、无写入与异常降级;真实 Codex 部署和接续证据位于上述里程碑记录。fixture 不能替代真实阈值与调度验收。
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Read-only context reminder for an active /goal, verified against the Claude Code 2.1.288 transcript format.
|
|
4
|
+
// Never writes files and never exits with code 2: anything unexpected degrades to "detection unavailable".
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
|
|
8
|
+
// Percent of the window; the handler command may override it with `--threshold N`.
|
|
9
|
+
const DEFAULT_THRESHOLD = 70;
|
|
10
|
+
// Hook input and transcript carry no window size; only windows checked against statusLine are trusted.
|
|
11
|
+
const WINDOWS = {
|
|
12
|
+
'claude-opus-5-5': 1000000,
|
|
13
|
+
'claude-sonnet-5-5': 1000000,
|
|
14
|
+
'claude-haiku-4-5-20251001': 200000,
|
|
15
|
+
};
|
|
16
|
+
const WINDOW_ENV = ['CLAUDE_CODE_DISABLE_1M_CONTEXT', 'DISABLE_COMPACT', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'];
|
|
17
|
+
|
|
18
|
+
class Unavailable extends Error {}
|
|
19
|
+
|
|
20
|
+
function readTranscript(file) {
|
|
21
|
+
if (typeof file !== 'string' || !file) throw new Unavailable('hook 输入缺少会话记录路径');
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = fs.readFileSync(file, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Unavailable('无法读取会话记录');
|
|
27
|
+
}
|
|
28
|
+
let goal = null;
|
|
29
|
+
let usage = null;
|
|
30
|
+
for (const line of text.split('\n')) {
|
|
31
|
+
let record;
|
|
32
|
+
try {
|
|
33
|
+
record = JSON.parse(line);
|
|
34
|
+
} catch {
|
|
35
|
+
continue; // blank lines and the line still being written
|
|
36
|
+
}
|
|
37
|
+
if (!record || record.isSidechain === true) continue;
|
|
38
|
+
if (record.type === 'attachment' && record.attachment?.type === 'goal_status') goal = record.attachment;
|
|
39
|
+
else if (record.type === 'system' && record.subtype === 'compact_boundary') usage = null;
|
|
40
|
+
else if (record.type === 'assistant' && record.message?.usage && record.message.model !== '<synthetic>'
|
|
41
|
+
&& !record.isApiErrorMessage) usage = record.message;
|
|
42
|
+
}
|
|
43
|
+
// Same rule Claude Code uses when resuming a goal.
|
|
44
|
+
return { active: !!goal && !goal.met && !goal.failed, message: usage };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tokens(message) {
|
|
48
|
+
const { usage } = message;
|
|
49
|
+
const last = Array.isArray(usage.iterations) && usage.iterations.length ? usage.iterations.at(-1) : usage;
|
|
50
|
+
let used = 0;
|
|
51
|
+
for (const field of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
|
|
52
|
+
const value = last?.[field] ?? 0;
|
|
53
|
+
if (!Number.isInteger(value) || value < 0) throw new Unavailable('用量统计格式未知');
|
|
54
|
+
used += value;
|
|
55
|
+
}
|
|
56
|
+
return used;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function threshold(args) {
|
|
60
|
+
if (args.length === 0) return DEFAULT_THRESHOLD;
|
|
61
|
+
if (args.length === 2 && args[0] === '--threshold' && /^[1-9][0-9]?$/.test(args[1])) return Number(args[1]);
|
|
62
|
+
throw new Unavailable('阈值参数无效,应为 --threshold 加 1–99 的整数');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function contextWindow(model) {
|
|
66
|
+
const override = WINDOW_ENV.find((name) => process.env[name]);
|
|
67
|
+
if (override) throw new Unavailable(`设置了改变上下文窗口的环境变量 ${override}`);
|
|
68
|
+
if (!Object.hasOwn(WINDOWS, model)) throw new Unavailable(`模型 ${String(model)} 的上下文窗口未经验证`);
|
|
69
|
+
return WINDOWS[model];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reminder(shown, limit) {
|
|
73
|
+
return `上下文估算使用率 ${shown}% 已超过 ${limit}%,当前 goal 仍 active。`
|
|
74
|
+
+ '本提醒优先于继续推进 goal 的指令:即使收到「继续」类消息,也先完成收尾并结束本回合,新工作在新对话中进行。'
|
|
75
|
+
+ '1) 停止开始新的实质任务;已启动的工作处理到可交接,不为收尾发起全量测试或额外研究。'
|
|
76
|
+
+ '2) 按当前任务已有约定更新进度与记录:已完成、未完成、已验证、未验证、下一步;没有既有记录时在最终回复交接,不新建文件。'
|
|
77
|
+
+ '3) 停止仍在运行的后台命令与子 agent(用户明确要求保留的除外),并在交接中列出;否则它们会在 goal 暂停后唤醒会话。'
|
|
78
|
+
+ '4) 不要声称 goal 条件已满足或不可能,写明「因上下文收尾而暂停,工作未完成」。'
|
|
79
|
+
+ '5) 完成后结束本回合,并告知用户:guard 会在回合结束时暂停 goal,请在同一项目目录新开对话或 /clear,'
|
|
80
|
+
+ '提供计划路径或交接摘要,并重新设置 /goal。'
|
|
81
|
+
+ '重复出现的本提醒属于同一次收尾,不重复追加记录。';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function backgroundList(tasks) {
|
|
85
|
+
const clip = (text) => (text.length > 200 ? `${text.slice(0, 200)}…` : text);
|
|
86
|
+
const items = tasks.map((task) => `${task?.id}(${clip(String(task?.command ?? task?.description ?? task?.type))})`);
|
|
87
|
+
return `仍在运行的后台任务:${items.join(';')}。请停止它们并在交接中列出。`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function endTurn(shown, limit, background) {
|
|
91
|
+
return `上下文估算使用率 ${shown}% 已超过 ${limit}%,guard 已结束本回合,goal 暂停,工作未完成。`
|
|
92
|
+
+ '请在同一项目目录新开对话(或 /clear),提供计划路径或交接摘要并说明继续执行;新对话不继承 goal,'
|
|
93
|
+
+ '如需继续使用本 guard,请重新设置 /goal。不再需要原 goal 时可在本会话 /goal clear。'
|
|
94
|
+
+ '如交接不完整,在本会话发一句「按上下文收尾提醒补交接」,回合结束后会再次暂停。'
|
|
95
|
+
+ (background ? `仍有 ${background} 个后台任务在运行,它们结束或 check-in 时会短暂唤醒会话,guard 会再次结束回合。` : '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unavailable(reason) {
|
|
99
|
+
return {
|
|
100
|
+
systemMessage: `context goal guard 检测不可用:${reason};参见 docs/claude-context-goal-guard.md 排查,`
|
|
101
|
+
+ '或从 .claude/settings.local.json 移除本 guard',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function decide(input) {
|
|
106
|
+
const event = input?.hook_event_name;
|
|
107
|
+
if ((event !== 'PostToolBatch' && event !== 'Stop') || input.agent_id) return null;
|
|
108
|
+
try {
|
|
109
|
+
const { active, message } = readTranscript(input.transcript_path);
|
|
110
|
+
if (!active || !message) return null;
|
|
111
|
+
const limit = threshold(process.argv.slice(2));
|
|
112
|
+
const used = tokens(message);
|
|
113
|
+
const window = contextWindow(message.model);
|
|
114
|
+
if (used * 100 <= window * limit) return null;
|
|
115
|
+
const shown = Math.round((used / window) * 100);
|
|
116
|
+
if (event === 'PostToolBatch') {
|
|
117
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: reminder(shown, limit) } };
|
|
118
|
+
}
|
|
119
|
+
const background = Array.isArray(input.background_tasks) ? input.background_tasks : [];
|
|
120
|
+
if (input.stop_hook_active !== true) {
|
|
121
|
+
const reason = reminder(shown, limit) + (background.length ? backgroundList(background) : '')
|
|
122
|
+
+ '若已完成收尾,只需一句确认后结束。';
|
|
123
|
+
return { decision: 'block', reason };
|
|
124
|
+
}
|
|
125
|
+
return { continue: false, stopReason: endTurn(shown, limit, background.length) };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Warn once per stop instead of on every tool batch; never block or end the turn on uncertainty.
|
|
128
|
+
if (event !== 'Stop') return null;
|
|
129
|
+
return unavailable(error instanceof Unavailable ? error.message : '内部错误');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let output = null;
|
|
134
|
+
try {
|
|
135
|
+
output = decide(JSON.parse(fs.readFileSync(0, 'utf8')));
|
|
136
|
+
} catch {
|
|
137
|
+
output = null;
|
|
138
|
+
}
|
|
139
|
+
if (output) process.stdout.write(JSON.stringify(output));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolBatch": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-goal-guard.js\" --threshold 70",
|
|
9
|
+
"timeout": 5
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"Stop": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-goal-guard.js\" --threshold 70",
|
|
20
|
+
"timeout": 5
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only context reminder for the verified Codex 0.160.0 transcript format."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Percent of the window; the handler command may override it with `--threshold N`.
|
|
14
|
+
DEFAULT_THRESHOLD = 65
|
|
15
|
+
|
|
16
|
+
UNAVAILABLE = {
|
|
17
|
+
"systemMessage": "上下文 goal guard 检测不可用;请核对 CODEX_HOME、会话格式及随附的启用说明。"
|
|
18
|
+
"持续异常时请在 /hooks 中停用本 guard 并按说明排查。"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def root_transcript(data, home):
|
|
23
|
+
if data.get("agent_id"):
|
|
24
|
+
return None
|
|
25
|
+
session_id = data["session_id"]
|
|
26
|
+
if not isinstance(session_id, str):
|
|
27
|
+
raise ValueError("invalid session identity")
|
|
28
|
+
uuid.UUID(session_id)
|
|
29
|
+
path = Path(data["transcript_path"])
|
|
30
|
+
if not path.is_absolute() or not path.resolve().is_relative_to((home / "sessions").resolve()):
|
|
31
|
+
raise ValueError("transcript outside current home")
|
|
32
|
+
with path.open("rb") as stream:
|
|
33
|
+
header = stream.readline(1024 * 1024)
|
|
34
|
+
if not header.endswith(b"\n"):
|
|
35
|
+
raise ValueError("incomplete session metadata")
|
|
36
|
+
record = json.loads(header)
|
|
37
|
+
if record["type"] != "session_meta":
|
|
38
|
+
raise ValueError("missing session metadata")
|
|
39
|
+
meta = record["payload"]
|
|
40
|
+
if not isinstance(meta, dict):
|
|
41
|
+
raise ValueError("unknown session metadata")
|
|
42
|
+
source = meta.get("source")
|
|
43
|
+
if meta.get("parent_thread_id") or isinstance(source, dict) and "subagent" in source:
|
|
44
|
+
return None
|
|
45
|
+
if (meta["id"] != session_id or meta["session_id"] != session_id
|
|
46
|
+
or source not in ("cli", "exec", "vscode") or meta["cli_version"] != "0.160.0"):
|
|
47
|
+
raise ValueError("unverified identity or format")
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def goal_active(home, thread):
|
|
52
|
+
database = home / "goals_1.sqlite"
|
|
53
|
+
con = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.2)
|
|
54
|
+
try:
|
|
55
|
+
found = con.execute("SELECT status FROM thread_goals WHERE thread_id = ?", (thread,)).fetchone()
|
|
56
|
+
finally:
|
|
57
|
+
con.close()
|
|
58
|
+
if found is not None and not isinstance(found[0], str):
|
|
59
|
+
raise ValueError("unknown goal status format")
|
|
60
|
+
return found is not None and found[0] == "active"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reverse_records(stream):
|
|
64
|
+
stream.seek(0, os.SEEK_END)
|
|
65
|
+
remaining = stream.tell()
|
|
66
|
+
pending = b""
|
|
67
|
+
discard_tail = True
|
|
68
|
+
while remaining:
|
|
69
|
+
size = min(65536, remaining)
|
|
70
|
+
remaining -= size
|
|
71
|
+
stream.seek(remaining)
|
|
72
|
+
parts = (stream.read(size) + pending).split(b"\n")
|
|
73
|
+
pending = parts.pop(0)
|
|
74
|
+
for line in reversed(parts):
|
|
75
|
+
# Drop the trailing fragment, or the empty line after a final newline.
|
|
76
|
+
if discard_tail:
|
|
77
|
+
discard_tail = False
|
|
78
|
+
elif line.strip():
|
|
79
|
+
yield json.loads(line)
|
|
80
|
+
if pending.strip() and not discard_tail:
|
|
81
|
+
yield json.loads(pending)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def context_usage(path, model):
|
|
85
|
+
if not isinstance(model, str) or not model:
|
|
86
|
+
raise ValueError("missing current model")
|
|
87
|
+
candidate = None
|
|
88
|
+
newer_context = False
|
|
89
|
+
windows = []
|
|
90
|
+
window_ids = []
|
|
91
|
+
with path.open("rb") as stream:
|
|
92
|
+
for record in reverse_records(stream):
|
|
93
|
+
kind = record["type"]
|
|
94
|
+
payload = record["payload"]
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
raise ValueError("unknown record payload")
|
|
97
|
+
if kind == "compacted" or (kind == "event_msg" and payload.get("type") in
|
|
98
|
+
("context_compacted", "model_context_changed", "model_changed")):
|
|
99
|
+
if candidate is None or newer_context:
|
|
100
|
+
raise ValueError("statistics invalidated")
|
|
101
|
+
return candidate
|
|
102
|
+
if kind == "turn_context":
|
|
103
|
+
if payload["model"] != model:
|
|
104
|
+
raise ValueError("statistics belong to another model")
|
|
105
|
+
context = payload.get("context_window")
|
|
106
|
+
if context is not None:
|
|
107
|
+
identity = context["window_id"]
|
|
108
|
+
if not isinstance(identity, str) or not identity:
|
|
109
|
+
raise ValueError("unknown context identity")
|
|
110
|
+
window_ids.append(identity)
|
|
111
|
+
if "model_context_window" in payload:
|
|
112
|
+
windows.append(payload["model_context_window"])
|
|
113
|
+
if candidate is not None:
|
|
114
|
+
if any(type(w) is not int or w != candidate[1] for w in windows):
|
|
115
|
+
raise ValueError("context window changed")
|
|
116
|
+
if len(set(window_ids)) > 1 or window_ids and context is None:
|
|
117
|
+
raise ValueError("context identity changed")
|
|
118
|
+
return candidate
|
|
119
|
+
newer_context = True
|
|
120
|
+
elif kind == "event_msg" and payload.get("type") == "token_count" and candidate is None:
|
|
121
|
+
info = payload["info"]
|
|
122
|
+
used = info["last_token_usage"]["total_tokens"]
|
|
123
|
+
window = info["model_context_window"]
|
|
124
|
+
if type(used) is not int or used < 0 or type(window) is not int or window <= 0:
|
|
125
|
+
raise ValueError("invalid token statistics")
|
|
126
|
+
candidate = used, window
|
|
127
|
+
elif kind == "session_meta":
|
|
128
|
+
if candidate is not None and not newer_context:
|
|
129
|
+
return candidate
|
|
130
|
+
break
|
|
131
|
+
elif kind not in ("event_msg", "response_item", "token_usage_record", "world_state"):
|
|
132
|
+
raise ValueError("unknown record format")
|
|
133
|
+
raise ValueError("no current token statistics")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def threshold(args):
|
|
137
|
+
if not args:
|
|
138
|
+
return DEFAULT_THRESHOLD
|
|
139
|
+
if len(args) == 2 and args[0] == "--threshold" and re.fullmatch(r"[1-9][0-9]?", args[1]):
|
|
140
|
+
return int(args[1])
|
|
141
|
+
raise ValueError("invalid threshold argument")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def reminder(used, window, limit):
|
|
145
|
+
return (
|
|
146
|
+
f"本地上下文估算使用率 {used / window * 100:.2f}% 已严格超过 {limit}%,当前 goal 仍 active。"
|
|
147
|
+
"停止开始新的实质任务,先将已启动工作处理到可交接状态;沿用已有任务计划和记录更新进度,"
|
|
148
|
+
"写清已完成、未完成、已验证、未验证、下一步及仍在运行的进程。没有既有记录时在最终回复交接即可,"
|
|
149
|
+
f"记录写入失败也先在回复保存最小交接摘要。仅当用户对当前 goal 有显式的 {limit}% 暂停请求时,"
|
|
150
|
+
"按该请求使用原生 goal 工具设为 paused 并核对;不清楚则向用户确认并如实说明尚未暂停,提示 /goal pause。"
|
|
151
|
+
"hook 不构成暂停授权;后续 resume 撤销原请求。不要以 complete 代替暂停或直接写数据库。"
|
|
152
|
+
"然后报告真实状态、记录位置或交接摘要,提醒用户在同一工作目录自行新开对话继续,"
|
|
153
|
+
"新对话不会继承 goal,需用户手动设置 goal 并重新明确暂停策略。重复提醒视为同一次收尾。"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run(data, args=()):
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
raise ValueError("invalid hook input")
|
|
160
|
+
event = data["hook_event_name"]
|
|
161
|
+
if event not in ("PreToolUse", "Stop"):
|
|
162
|
+
return {}
|
|
163
|
+
if event == "Stop" and type(data["stop_hook_active"]) is not bool:
|
|
164
|
+
raise ValueError("invalid stop state")
|
|
165
|
+
home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
|
|
166
|
+
if not home.is_absolute():
|
|
167
|
+
raise ValueError("unknown Codex home")
|
|
168
|
+
home = home.resolve()
|
|
169
|
+
path = root_transcript(data, home)
|
|
170
|
+
if path is None or not goal_active(home, data["session_id"]):
|
|
171
|
+
return {}
|
|
172
|
+
limit = threshold(args)
|
|
173
|
+
used, window = context_usage(path, data["model"])
|
|
174
|
+
if used * 100 <= window * limit:
|
|
175
|
+
return {}
|
|
176
|
+
if event == "PreToolUse":
|
|
177
|
+
return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": reminder(used, window, limit)}}
|
|
178
|
+
if not data["stop_hook_active"]:
|
|
179
|
+
return {"decision": "block", "reason": reminder(used, window, limit)}
|
|
180
|
+
return {"systemMessage": f"上下文超过 {limit}%,goal 仍 active,收尾暂停尚未成功。"
|
|
181
|
+
"请在原对话执行 /goal pause,再按交接说明自行新开对话;此告警不停止 goal 自动续跑。"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
try:
|
|
186
|
+
result = run(json.load(sys.stdin), sys.argv[1:])
|
|
187
|
+
except (ValueError, TypeError, KeyError, OSError, sqlite3.Error):
|
|
188
|
+
result = UNAVAILABLE
|
|
189
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "python3 \"/path/to/My Project/.codex/hooks/context-goal-guard.py\" --threshold 65",
|
|
9
|
+
"timeout": 3
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"Stop": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "python3 \"/path/to/My Project/.codex/hooks/context-goal-guard.py\" --threshold 65",
|
|
20
|
+
"timeout": 3
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgent-ai/skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "XGENT skills 与 Claude Code 辅助工具:通过 npx 为项目安装 hooks、启用 settings,并安装 vendor 的 impeccable",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|
|
20
20
|
".claude/hooks",
|
|
21
|
+
"hooks/codex/context-goal-guard.py",
|
|
22
|
+
"hooks/codex/hooks.example.json",
|
|
23
|
+
"docs/codex-context-goal-guard.md",
|
|
24
|
+
"hooks/claude/context-goal-guard.js",
|
|
25
|
+
"hooks/claude/settings.example.json",
|
|
26
|
+
"docs/claude-context-goal-guard.md",
|
|
21
27
|
"skills",
|
|
22
28
|
"vendor/impeccable/bundle",
|
|
23
29
|
"vendor/impeccable/VERSION.json",
|
package/skills/dev-plan/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dev-plan
|
|
3
|
-
description:
|
|
3
|
+
description: 仅在用户要求撰写、修订或评审开发计划、技术设计、立项方案与实施路线图时使用;直接实现、按既有计划实施、goal 执行、续做或回写进度时不使用。基于需求、仓库规则与代码事实交付可执行、可跨对话续做的计划。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# dev-plan · 基于代码事实的可执行开发计划
|
|
@@ -9,7 +9,9 @@ description: 基于用户需求、仓库规则和当前代码事实,撰写或
|
|
|
9
9
|
|
|
10
10
|
## 适用边界:只管写与评审,不管实施
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
按既有计划实施、goal 执行或续做不用本 skill:「实施者定位」与「实施进度」已带齐实施需要的全部约定(进度回写、独立记录格式、subagent 并行、回写后自查),实施者直接读计划。同一对话里写完计划接着实施,也按计划顶部两节执行,不再读本 skill 的参考。由此推出一条写作约束:**实施期需要的任何规则都必须写进骨架的这两节**,不能只留在本 skill 里。
|
|
13
|
+
|
|
14
|
+
生成计划不出现本 skill 名称、目录或脚本路径,包括“不要加载”的否定提醒;实施入口用正向指令说明先读哪些内容、如何推进与回写。计划提到规划、里程碑或就地修订实施偏差,不等于用户要求重新撰写或评审计划。若误在实施期加载本 skill,直接返回计划继续实施,不读取骨架或参考、不重走规划流程。
|
|
13
15
|
|
|
14
16
|
## 角色定位(写计划前先采用)
|
|
15
17
|
|
|
@@ -35,7 +37,7 @@ description: 基于用户需求、仓库规则和当前代码事实,撰写或
|
|
|
35
37
|
|
|
36
38
|
## 交付约定
|
|
37
39
|
|
|
38
|
-
- **模板**:除非用户显式指定另一份模板,否则必须通读并使用本 skill 自带的 [references/plan-skeleton.md](references/plan-skeleton.md)。不要探测或依赖仓库内的 `PLAN-TEMPLATE
|
|
40
|
+
- **模板**:除非用户显式指定另一份模板,否则必须通读并使用本 skill 自带的 [references/plan-skeleton.md](references/plan-skeleton.md)。不要探测或依赖仓库内的 `PLAN-TEMPLATE`;出仓后的目标仓通常没有它。计划全文不提用了哪份 skill、骨架或参考——读者是实现者,写作用了什么工具与他无关。
|
|
39
41
|
- **落盘**:用户指定路径优先;否则仓库根存在 `goal/` 时写 `goal/<代号>.md`,不存在时写 `docs/plan/<代号>.md`(按需创建目录)。目标文件已存在时先确认再覆盖。
|
|
40
42
|
- **范例**:输出目录里若有同类型旧计划,可选读 1–2 份校准项目惯例;旧计划只是样例,不是事实源,路径、行为和数字仍须核实。
|
|
41
43
|
- **代码基线**:Git 仓库中记录调查日期、commit SHA 和工作区是否 dirty;非 Git 仓库记录调查日期与可用版本标识。dirty 时说明相关事实来自当前工作树,不能只写 HEAD。只保留最近一次核对的一条基线,改稿时就地更新,不追加复核经过。
|
|
@@ -129,7 +131,7 @@ description: 基于用户需求、仓库规则和当前代码事实,撰写或
|
|
|
129
131
|
- 写清实际前置成果及验证、可并行范围与共享文件/资源的单一负责人。前置已满足、契约稳定且互不干扰才可并行;按任务规模与集成成本决定是否委派,不为并行硬拆。复杂调度按需读 [references/execution-workflow.md](references/execution-workflow.md)。
|
|
130
132
|
- 每个里程碑的退出条件逐行以「回写『实施进度』」结尾,防止完成后漏记。
|
|
131
133
|
- 在计划顶部初始化实施进度:总里程碑数、当前状态、最近完成、下一步、阻塞和代码基线必须与里程碑表一致;计划尚未实施时如实写“0/N、尚未开始、下一步 M1”,不得预填完成记录。
|
|
132
|
-
- 骨架开头的“实施须知”及“实施者定位”“实施进度”协议原样带入计划,不改写、不删减:它们是给执行计划的 agent
|
|
134
|
+
- 骨架开头的“实施须知”及“实施者定位”“实施进度”协议原样带入计划,不改写、不删减:它们是给执行计划的 agent 的完整合同,与写计划的架构师定位是两回事。回写后自查按计划内条目直接核对,不引入本 skill 的脚本依赖;`check_paths.sh` 与 `check_progress.sh` 留在撰写/评审期使用,不列入计划的实施或终验命令。
|
|
133
135
|
- 计划按风险和改动面裁剪,不用固定行数或章节占比衡量质量;调查原始材料不倾倒进正文,只保留结论与来源。
|
|
134
136
|
|
|
135
137
|
## 第 5 步:自检、就绪与交付
|
|
@@ -138,7 +140,7 @@ description: 基于用户需求、仓库规则和当前代码事实,撰写或
|
|
|
138
140
|
2. 对照需求账本或映射表检查 `R-* / NFR-* / C-* / A-*`:每条有设计、验证、排除去向或阻塞说明;同一事实和断言没有多处展开。
|
|
139
141
|
3. 按 `references/architecture-quality.md` 的一致性清单检查凭证/scope、接口/调用方、数据约束/生命周期、设计/验证、发布/回滚和多处重复口径。
|
|
140
142
|
4. 建失败模式表:高影响失败至少写触发、爆炸半径、数据后果、用户表现、检测、恢复与验证;简单局部任务可说明“不引入新的运行期失败模式”及依据。
|
|
141
|
-
5.
|
|
143
|
+
5. 检查顶部实施协议为骨架原文在位,计划全文没有本 skill 名称、目录或脚本依赖;每个里程碑退出条件以「回写『实施进度』」结尾,依赖与并行边界明确,阶段收口覆盖被集中的验收且保留必要风险门;跑 `"$SKILL_DIR/scripts/check_progress.sh" <计划文件> <仓库根>` 确认进度与里程碑一致、记录链接有效;初稿不预填完成事实,详情不写回计划。
|
|
142
144
|
6. 最终定级:存在影响范围、安全、数据、外部契约或关键 NFR 的未决项即 `Blocked`;只等非阻塞评审为 `Proposed`;所有实施前置已解决才是 `Ready`。
|
|
143
145
|
7. 汇报计划路径与状态,并单列:待拍板问题、与用户假设冲突的事实、被砍/推迟的范围、未在真实环境完成的调查或验证。
|
|
144
146
|
|
|
@@ -151,5 +153,5 @@ description: 基于用户需求、仓库规则和当前代码事实,撰写或
|
|
|
151
153
|
- 不吞需求,不把重大假设藏进正文,不把阻塞计划标为 `Ready`。
|
|
152
154
|
- 不把未通过退出条件的里程碑写成已完成,不用聊天记录代替计划内的简要进度与独立的实现/验收记录;不在续做时批量加载历史记录。
|
|
153
155
|
- 不为假想扩展造接缝、基础设施或抽象,也不照搬通用架构示例覆盖仓库现实。
|
|
154
|
-
-
|
|
156
|
+
- 计划里不写写作过程:用了哪份 skill / 模板 / 参考资料、读过哪些规则文件的清单、调查或复核做了几轮、评审意见的采纳与改稿经过、“本次只写/修订计划、未实施”这类写作当时的状态、“本轮/刚才”这类只在写作对话里成立的指代(来源写“用户 YYYY-MM-DD 确认”)。它们既不是实施事实也不是里程碑进展,会随实施过期、误导实施者、白占上下文;提到 skill 还会诱导实施者去加载只有写计划才需要的内容。
|
|
155
157
|
- 不在出仓 skill 中硬编码某个仓库的模板、技术栈、端口、样板计划、本机路径或业务约定;这些必须在运行时从目标仓规则和代码读取。
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
> 同一信息只展开一次:已有 PRD 用 ID 与来源引用,账本已含完整设计/验收落点时删 §13;其余章节与里程碑引用定义和检查 ID。模块内测试就近完成,昂贵集成验收按可行性集中到阶段收口,独立风险门保留。
|
|
7
7
|
> 同理,写作过程不进计划:用了哪份 skill、骨架或参考资料,读过哪些规则文件、哪些确认无约束,调查、复核与评审改稿的经过,以及“本次只写计划、未实施”这类写作当时的状态,实现者都不需要知道;计划只陈述当前结论。
|
|
8
8
|
> 仓库规则里真正约束实现的条目落成 §0.1 的 `C-*` 或 §12 的一行一坑,带规则路径与被约束的具体行为。
|
|
9
|
-
>
|
|
9
|
+
> 开头的「实施须知」与「实施者定位」「实施进度」两节是实施期的完整合同:实施需要的规则只能写在这两节里,计划全文不出现本 skill 名称、目录或脚本依赖。「实施须知」与两节的协议文字原样带入;恢复快照与完成记录按真实状态初始化。
|
|
10
10
|
|
|
11
11
|
```markdown
|
|
12
12
|
# <代号> · <项目名> · <功能名>(<形态 · 关键架构特征>)
|
|
13
13
|
|
|
14
|
-
>
|
|
14
|
+
> **实施须知:直接依据本计划、仓库规则和当前代码实施或续做。** 先读下方「实施者定位」「实施进度」,核对工作树,再按相关正文与里程碑推进、验证并回写进度;使用 goal 执行时也遵循这些约定。
|
|
15
15
|
>
|
|
16
16
|
> **计划状态:<Ready / Proposed / Blocked>**
|
|
17
17
|
>
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
**独立记录**:按需建 `<计划名>.records/M<n>.md`,头部写计划链接、更新时区、状态及验证基线(commit SHA;未提交写 dirty@起始SHA 与关键改动路径)。正文只写交付行为/路径、必要偏差、未完项与继续动作;验收按 `检查 ID/覆盖范围 | 命令或步骤 | 结果/必要证据` 记录,共同环境和基线在头部写一次,有差异再单列。同一测试组可覆盖多条退出条件,须逐条可追溯;引用原证据与复用依据,不复制断言全文、成功日志或调试流水。未运行、失败、环境缺失明确区分;只有复杂交接、排错或证据需独立保存时才另建任务记录/日志/截图并链接。
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**回写后自查**:每次回写后直接核对以下条目:「当前进度 n/N」的 N 等于里程碑总数,n 只计状态为「已完成」的行;「最近完成」对应最后一条已完成记录,不按编号推算;链接指向已落盘文件。上述结构、计数与链接检查不证明验收真实通过。
|
|
44
44
|
|
|
45
45
|
### 恢复快照
|
|
46
46
|
|
|
@@ -258,9 +258,9 @@
|
|
|
258
258
|
|
|
259
259
|
- [ ] 默认使用本文件;未探测或依赖目标仓库中的模板。
|
|
260
260
|
- [ ] 计划记录调查日期、commit/version 和 dirty 状态。
|
|
261
|
-
- [ ]
|
|
261
|
+
- [ ] 全文没有本 skill 名称、目录、脚本依赖或写作过程元信息:用了哪份 skill/骨架/参考、读过哪些规则文件的清单、调查步骤、评审处置与改稿经过、“本次只写计划”类状态;相关约束已落成 `C-*` 或 §12。
|
|
262
262
|
- [ ] 顶部实施进度已按真实状态初始化,0/N、M1、里程碑总数和代码基线互相一致;没有预填虚假完成记录。
|
|
263
|
-
- [ ]
|
|
263
|
+
- [ ] 「实施进度」的回写协议为骨架原文在位,自查条目自包含;每个里程碑的退出条件都以「回写『实施进度』」结尾。
|
|
264
264
|
- [ ] 计划只留简要进度与链接;记录按检查组保留可追溯证据,不默认建子任务文档,进行中/阻塞不计完成。
|
|
265
265
|
- [ ] 依赖落到实际成果和前置检查;合并验收有覆盖范围与收口负责人,独立风险/分期门保留,局部通过不冒充终验。
|
|
266
266
|
- [ ] 并行收益与边界明确,共享文件/资源单人负责或隔离,主 agent 集成验收并单写计划。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# 用法: check_progress.sh <计划.md> [仓库根,默认 .]
|
|
3
|
-
#
|
|
3
|
+
# 撰写/评审时检查计划顶部「实施进度」的内部一致性,初稿应报 0/N;实施者按计划内条目自查。
|
|
4
4
|
# 只检查摘要、状态、ID 与引用文件存在性,不读取独立记录正文,也不证明验收已通过。
|
|
5
5
|
# 兼容旧版内联证据表并提示迁移;「压根没回写」仍需结合陈旧提示与实际工作判断。
|
|
6
6
|
set -euo pipefail
|
|
@@ -59,7 +59,7 @@ done
|
|
|
59
59
|
while IFS= read -r l; do
|
|
60
60
|
[ -n "$l" ] && err "恢复快照残留模板占位:$(trim "$l")"
|
|
61
61
|
done < <(sed 's/`[^`]*`//g' <<<"$snap" | grep -E '<[^<>]+>' || true)
|
|
62
|
-
#
|
|
62
|
+
# 兼容旧版协议:校验命令占位仍须替换;新版协议直接列自查条目,没有此填空。
|
|
63
63
|
if grep -qF '<进度校验命令>' <<<"$prog"; then
|
|
64
64
|
err "「回写后自查」的 <进度校验命令> 未替换为可运行命令或人工核对说明"
|
|
65
65
|
fi
|