cowork-flow 1.7.1 → 1.7.2
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/CHANGELOG.md +15 -1
- package/docs/release.md +17 -8
- package/package.json +3 -3
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/hooks/hooks.json +6 -6
- package/presets/qoder/hooks/inject-context.mjs +150 -0
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +1 -1
- package/template/skills/runtime-health/scripts/doctor.py +1 -1
- package/presets/qoder/hooks/inject-context.py +0 -122
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
### Added
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
## [1.7.2] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- GitHub 现在会在收到 `v*` tag 推送时自动发布 npm:`.github/workflows/publish.yml` 新增 `push: tags` 触发器与按 tag 归一的 `concurrency` 锁(同一 tag 的重复触发串行化而非并发抢发),`scripts/release.sh --no-publish` 改为推送分支与 tag 并把发布交给 CI,`gh release create` 降级为可选的 Release 说明入口。推送 tag 即发布,无需再手工建 Release。
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- 修复 tag 推送通道的空转:`verify-ubuntu` / `verify-windows` / `publish` 三个 job 的触发条件此前只认 `release` 事件和 `inputs.ref`,`on: push: tags` 触发工作流后三个 job 会被全部跳过——运行显示绿色却不会发布任何内容。三个门禁现在都放行 push 事件;Ubuntu 与 Windows 双平台验证仍是发布的硬前置。新增 `test/package.test.js` 断言锁住这一行为。
|
|
17
|
+
- npm 发布改用 **Trusted Publishing**(OIDC):publish job 声明 `id-token: write`,用 GitHub OIDC token 换取短期 npm 凭据,仓库不再保存长期 token。OIDC 交换需要 npm ≥ 11.5.1,而 Node 20 自带 npm 10,因此仅 publish job 改跑 Node 24(两个验证 job 保持 Node 20,与 `engines` 声明一致)。`setup-node` 保留 `registry-url` 以便 npm 读到 registry 后发起交换,但不提供 `NODE_AUTH_TOKEN`——占位符保持未解析,npm 先试交换、失败才回退。此前用 `NPM_TOKEN` secret 时,账号开启双因素认证会导致 CI 发布以 `EOTP` 失败。
|
|
18
|
+
- `package.json` 的 `bin` 路径去掉 `./` 前缀:npm 发布时会自动清理并告警,导致 tarball 里的 package.json 与源码不一致。
|
|
19
|
+
- Qoder 插件 hook 不再由宿主解析 `python`:改为 **Node 传输 shim**(`command: "node"` + `hooks/inject-context.mjs`),与 ZCode 插件同形态。修前在没有 `python` 命令的机器上(macOS 12.3+、只装 `python3` 的发行版),hook 以 `spawn python ENOENT` 静默失败,SessionStart / UserPromptSubmit 注入从未发生,日志里只有 `Non-blocking hook error(s) … Continuing execution`;Windows 上则依赖 `python.exe` 恰好在 PATH,商店版 Python 等环境同样哑。新 shim 由宿主必备的 Node 运行,内置 `COWORK_FLOW_PYTHON → python3 → python → py -3` 探测链自行定位项目运行时解释器,探测不到时保持 fail-open(exit 0)。升级动作:`cwf host add qoder --force` 刷新插件缓存后重启 Qoder。ZCode 的 `.js` shim(ESM 语法)在安装目录无 `package.json` 时依赖 Node ≥23 的语法探测,仓库声明的最低 Node 20 上会崩溃,建议后续同样改 `.mjs`——本次未动 ZCode。
|
|
20
|
+
|
|
8
21
|
## [1.7.1] - 2026-09-30
|
|
9
22
|
|
|
10
23
|
### Fixed
|
|
@@ -269,7 +282,8 @@
|
|
|
269
282
|
|
|
270
283
|
此版本未保留本地条目正文,详情见 [GitHub Release](https://github.com/fishlikewater/cowork-flow/releases/tag/v0.0.47)。
|
|
271
284
|
|
|
272
|
-
[Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.
|
|
285
|
+
[Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.2...HEAD
|
|
286
|
+
[1.7.2]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.1...v1.7.2
|
|
273
287
|
[1.7.1]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.0...v1.7.1
|
|
274
288
|
[1.7.0]: https://github.com/fishlikewater/cowork-flow/compare/v1.6.0...v1.7.0
|
|
275
289
|
[1.6.0]: https://github.com/fishlikewater/cowork-flow/compare/v1.5.0...v1.6.0
|
package/docs/release.md
CHANGED
|
@@ -36,7 +36,7 @@ npm run test:template:full
|
|
|
36
36
|
| `npm run release` | 发布 patch 版本 |
|
|
37
37
|
| `npm run release -- minor` | 发布 minor 版本 |
|
|
38
38
|
| `npm run release -- --version 0.1.0` | 使用指定版本号,跳过自动 bump |
|
|
39
|
-
| `npm run release -- minor --no-publish` | 完成版本、提交和 tag,但不执行 `npm publish
|
|
39
|
+
| `npm run release -- minor --no-publish` | 完成版本、提交和 tag,推送 commit 与 tag,但不执行 `npm publish`;由 CI 发布 |
|
|
40
40
|
| `npm run release -- --dry-run` | 运行发布前检查,停在版本 bump 之前 |
|
|
41
41
|
|
|
42
42
|
`--dry-run` 不是只读命令。它会先要求 Git 工作树和暂存区干净,再执行 `source:refresh` 和 `sync --force`,因此可能刷新仓库中被忽略的运行副本,并临时覆盖后恢复 `AGENTS.md`。它不会改版本文件、提交、打 tag 或发布;如果检查开始时已有本地修改,脚本会在任何刷新前停止。
|
|
@@ -48,7 +48,7 @@ npm run test:template:full
|
|
|
48
48
|
1. 确认工作树和更新日志符合预期。
|
|
49
49
|
2. 运行 `npm run release:check` 和 `git diff --check`。
|
|
50
50
|
3. 稳定性改动按上一节重复模板测试。
|
|
51
|
-
4. 运行 `npm run release -- <release-type>`;CI 通道使用 `--no-publish
|
|
51
|
+
4. 运行 `npm run release -- <release-type>`;CI 通道使用 `--no-publish`,它会把 tag 推给 CI。
|
|
52
52
|
5. 脚本同步 `package.json`、lockfile、`template/.cowork-flow/.version` 和所有随包插件清单。
|
|
53
53
|
6. 检查提交与 tag,再按下面的 CI 通道发布。
|
|
54
54
|
|
|
@@ -58,25 +58,34 @@ npm run test:template:full
|
|
|
58
58
|
|
|
59
59
|
推荐让 GitHub Actions 执行 `npm publish`:
|
|
60
60
|
|
|
61
|
-
1. 在本地运行 `npm run release -- <release-type> --no-publish
|
|
62
|
-
2. 先推送分支,再显式推送 tag:
|
|
61
|
+
1. 在本地运行 `npm run release -- <release-type> --no-publish`。脚本完成检查、版本、提交和 tag,然后推送分支与 tag:
|
|
63
62
|
|
|
64
63
|
```bash
|
|
65
64
|
git push
|
|
66
65
|
git push origin v<v>
|
|
67
66
|
```
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
2. 推送 tag 即触发 `.github/workflows/publish.yml`。工作流等待 Ubuntu 和 Windows 验证通过后,只从已存在的 `v<version>` tag 发布,并核对 tag、`package.json` 与 `template/.cowork-flow/.version` 的版本一致。
|
|
69
|
+
|
|
70
|
+
3. 需要给这个 tag 写 Release 说明时,再手动创建(不是发布的必要步骤):
|
|
70
71
|
|
|
71
72
|
```bash
|
|
72
73
|
gh release create v<v>
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
4. `.github/workflows/publish.yml` 等待 Ubuntu 和 Windows 验证通过后,只从已存在的 `v<version>` tag 发布,并核对 tag、`package.json` 与 `template/.cowork-flow/.version` 的版本一致。
|
|
76
|
-
|
|
77
76
|
不要在 tag 尚未存在于远端时运行 `gh release create`,否则 GitHub 可能从默认分支最新提交创建 tag,把发布指向错误提交。
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
同一 tag 的重复触发会被 concurrency 锁串行化,不会并发抢发;如果该版本已经发到 npm,重复运行会在 `npm publish` 阶段被 npm 拒绝,这是预期行为。上一次 CI 失败需要重发时,用 `workflow_dispatch` 指定同一个 tag 重跑即可。
|
|
79
|
+
|
|
80
|
+
PR 和发布使用同一套 core 门禁。
|
|
81
|
+
|
|
82
|
+
发布凭据走 **Trusted Publishing**:publish job 声明 `id-token: write`,把 GitHub OIDC token 交给 npm 换取短期凭据,仓库里不保存长期 token。登记在 npm 包页面(Settings → Trusted Publishers),声明仓库 `fishlikewater/cowork-flow` 与 workflow 文件 `publish.yml`。
|
|
83
|
+
|
|
84
|
+
该交换由 npm CLI 实现(11.5.1 起),而 Node 20 自带 npm 10,因此**只有 publish job 跑在 Node 24**;两个验证 job 仍用 Node 20,与 `package.json` 声明的 `engines` 保持一致。
|
|
85
|
+
|
|
86
|
+
publish job 的 `setup-node` **保留 `registry-url`**,但不提供 `NODE_AUTH_TOKEN`。npm 需要在 `.npmrc` 里读到 registry 才会发起 OIDC 交换——去掉它,发布会在未接触令牌端点的情况下直接以 `ENEEDAUTH` 失败。`setup-node` 写入的 `${NODE_AUTH_TOKEN}` 占位符则**故意不解析**:npm 会带着这个空凭据先尝试交换,只有交换被拒时才回退到它。
|
|
87
|
+
|
|
88
|
+
早期版本用 `NPM_TOKEN` secret。若账号开启双因素认证,classic token 无法完成交互式 OTP,CI 发布会以 `EOTP` 失败——这也是改用 Trusted Publishing 的原因。验证 job(Ubuntu / Windows)不接触任何发布凭据,只跑 `release:check`。
|
|
80
89
|
|
|
81
90
|
## 维护者命令
|
|
82
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cowork-flow",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "CLI for installing and maintaining cowork-flow collaboration templates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"type": "module",
|
|
32
32
|
"bin": {
|
|
33
|
-
"cwf": "
|
|
34
|
-
"cowork-flow": "
|
|
33
|
+
"cwf": "bin/cowork-flow.js",
|
|
34
|
+
"cowork-flow": "bin/cowork-flow.js"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"bin",
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "
|
|
8
|
+
"command": "node",
|
|
9
9
|
"args": [
|
|
10
|
-
"${QODER_PLUGIN_ROOT}/hooks/inject-context.
|
|
10
|
+
"${QODER_PLUGIN_ROOT}/hooks/inject-context.mjs"
|
|
11
11
|
],
|
|
12
12
|
"timeout": 30,
|
|
13
13
|
"statusMessage": "cowork-flow: 注入工作流状态"
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"hooks": [
|
|
21
21
|
{
|
|
22
22
|
"type": "command",
|
|
23
|
-
"command": "
|
|
23
|
+
"command": "node",
|
|
24
24
|
"args": [
|
|
25
|
-
"${QODER_PLUGIN_ROOT}/hooks/inject-context.
|
|
25
|
+
"${QODER_PLUGIN_ROOT}/hooks/inject-context.mjs"
|
|
26
26
|
],
|
|
27
27
|
"timeout": 30,
|
|
28
28
|
"statusMessage": "cowork-flow: 注入工作流状态"
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"hooks": [
|
|
37
37
|
{
|
|
38
38
|
"type": "command",
|
|
39
|
-
"command": "
|
|
39
|
+
"command": "node",
|
|
40
40
|
"args": [
|
|
41
|
-
"${QODER_PLUGIN_ROOT}/hooks/inject-context.
|
|
41
|
+
"${QODER_PLUGIN_ROOT}/hooks/inject-context.mjs"
|
|
42
42
|
],
|
|
43
43
|
"timeout": 30,
|
|
44
44
|
"statusMessage": "cowork-flow: 规范自查"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cowork-flow Qoder plugin hook — transport shim.
|
|
4
|
+
*
|
|
5
|
+
* Workflow facts (workflow-state, contract digest, decision
|
|
6
|
+
* anchor, stage contract, scope/spec edits) come from the
|
|
7
|
+
* Python source
|
|
8
|
+
* <scripts>/adapters/host/inject.py; this shim does
|
|
9
|
+
* transport only:
|
|
10
|
+
* 1. project resolution from the hook payload (`cwd`),
|
|
11
|
+
* then QODER_PROJECT_DIR / QODER_WORKING_DIR;
|
|
12
|
+
* 2. interpreter location, COWORK_FLOW_PYTHON -> python3
|
|
13
|
+
* -> python -> py -3 — the hook runs under the host's
|
|
14
|
+
* process PATH, which is not the user's login shell;
|
|
15
|
+
* 3. stdin passthrough to inject.py --host qoder, with
|
|
16
|
+
* stdout forwarded and a failing project runtime
|
|
17
|
+
* absorbed (exit 0, reason on stderr) — Qoder refuses
|
|
18
|
+
* the prompt on a non-zero hook exit, and the hook is
|
|
19
|
+
* machine-level, so it may meet a project runtime
|
|
20
|
+
* older than itself.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately named .mjs: the installed plugin cache has
|
|
23
|
+
* no package.json, so a plain .js entry point is parsed as
|
|
24
|
+
* CommonJS there and an `import` file would only load on
|
|
25
|
+
* Node versions that auto-detect module syntax.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync } from "fs";
|
|
29
|
+
import { dirname, join, resolve } from "path";
|
|
30
|
+
import { spawnSync } from "child_process";
|
|
31
|
+
|
|
32
|
+
const HOST = "qoder";
|
|
33
|
+
const DIR_WORKFLOW = ".cowork-flow";
|
|
34
|
+
// hooks.json grants 30s per event.
|
|
35
|
+
const SPAWN_TIMEOUT_MS = 25000;
|
|
36
|
+
|
|
37
|
+
function readHookInput() {
|
|
38
|
+
if (process.stdin.isTTY) return { raw: "", parsed: {} };
|
|
39
|
+
let raw = "";
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(0, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return { raw: "", parsed: {} };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
// Tolerate a UTF-8 BOM and surrounding whitespace: the payload is
|
|
47
|
+
// forwarded verbatim either way, only the parse needs it clean.
|
|
48
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, "").trim());
|
|
49
|
+
return { raw, parsed: parsed && typeof parsed === "object" ? parsed : {} };
|
|
50
|
+
} catch {
|
|
51
|
+
return { raw, parsed: {} };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findWorkflowRoot(startDir) {
|
|
56
|
+
if (typeof startDir !== "string" || !startDir.trim()) return null;
|
|
57
|
+
let current;
|
|
58
|
+
try {
|
|
59
|
+
current = resolve(startDir);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
for (;;) {
|
|
64
|
+
if (existsSync(join(current, DIR_WORKFLOW))) return current;
|
|
65
|
+
const parent = dirname(current);
|
|
66
|
+
if (parent === current) return null;
|
|
67
|
+
current = parent;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findProjectRoot(parsedInput) {
|
|
72
|
+
const candidates = [
|
|
73
|
+
parsedInput?.cwd,
|
|
74
|
+
process.env.QODER_PROJECT_DIR,
|
|
75
|
+
process.env.QODER_WORKING_DIR,
|
|
76
|
+
process.cwd(),
|
|
77
|
+
];
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
const root = findWorkflowRoot(candidate);
|
|
80
|
+
if (root) return root;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pythonCandidates() {
|
|
86
|
+
const list = [];
|
|
87
|
+
const env = (process.env.COWORK_FLOW_PYTHON || "").trim();
|
|
88
|
+
if (env) list.push([env]);
|
|
89
|
+
list.push(["python3"], ["python"]);
|
|
90
|
+
if (process.platform === "win32") list.push(["py", "-3"]);
|
|
91
|
+
return list;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function main() {
|
|
95
|
+
if (
|
|
96
|
+
process.env.COWORK_FLOW_HOOKS === "0" ||
|
|
97
|
+
process.env.COWORK_FLOW_DISABLE_HOOKS === "1"
|
|
98
|
+
) {
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { raw, parsed } = readHookInput();
|
|
103
|
+
const root = findProjectRoot(parsed);
|
|
104
|
+
if (!root) process.exit(0);
|
|
105
|
+
const injectScript = join(
|
|
106
|
+
root,
|
|
107
|
+
DIR_WORKFLOW,
|
|
108
|
+
"scripts",
|
|
109
|
+
"adapters",
|
|
110
|
+
"host",
|
|
111
|
+
"inject.py"
|
|
112
|
+
);
|
|
113
|
+
if (!existsSync(injectScript)) process.exit(0);
|
|
114
|
+
|
|
115
|
+
for (const candidate of pythonCandidates()) {
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = spawnSync(
|
|
119
|
+
candidate[0],
|
|
120
|
+
[...candidate.slice(1), injectScript, "--host", HOST],
|
|
121
|
+
{
|
|
122
|
+
input: raw,
|
|
123
|
+
cwd: root,
|
|
124
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (result.error || result.status === null) continue;
|
|
133
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
134
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
135
|
+
// The interpreter ran; its exit status is the project runtime's verdict,
|
|
136
|
+
// not ours. Forwarding it would let a stale project copy fail the event
|
|
137
|
+
// this hook only decorates.
|
|
138
|
+
if (result.status !== 0) {
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`cowork-flow qoder hook: inject.py exited ${result.status}; skipping injection\n`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
// No interpreter could run the entry: stay silent rather
|
|
146
|
+
// than break the host's event stream (fail-open).
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
main();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.7.
|
|
1
|
+
1.7.2
|
|
@@ -23,7 +23,7 @@ would make the digest self-referential).
|
|
|
23
23
|
| opencode | plugin `experimental.chat.system.transform` + `shell.env` | system-prompt section push / env object |
|
|
24
24
|
| dsh | preset plugin system-prompt section | named section, replace semantics |
|
|
25
25
|
| kimi-code | user-level `config.toml` (`$KIMI_CODE_HOME`, default `~/.kimi-code/`) `[[hooks]]` row on `UserPromptSubmit` → shim (`cowork-flow-inject.mjs`) → `inject.py --host kimi-code` | bare-text stdout appended to the prompt context, no `hookSpecificOutput` envelope |
|
|
26
|
-
| qoder | machine-level plugin `hooks/hooks.json` (`${QODER_PLUGIN_ROOT}/hooks/inject-context.
|
|
26
|
+
| qoder | machine-level plugin `hooks/hooks.json` (node, `${QODER_PLUGIN_ROOT}/hooks/inject-context.mjs`) → project `.cowork-flow/scripts/.../inject.py --host qoder`, stdout JSON | `hookSpecificOutput.{hookEventName, additionalContext}` plus a top-level `systemMessage` (a CLI-visible trace; the model context is unchanged); `PostToolUse` is not blockable there, so the edit advisory rides `additionalContext` on exit 0 |
|
|
27
27
|
|
|
28
28
|
The qoder row carries one delta beyond the shared envelope: `systemMessage`,
|
|
29
29
|
emitted only on the two injecting events (`SessionStart`, `UserPromptSubmit`)
|
|
@@ -984,7 +984,7 @@ def check_qoder_plugin(repo_root: Path) -> list[dict[str, str]]:
|
|
|
984
984
|
"manifest is on disk there; the host cannot load a missing payload",
|
|
985
985
|
"cwf host add qoder --force",
|
|
986
986
|
)
|
|
987
|
-
for relative in ("hooks/hooks.json", "hooks/inject-context.
|
|
987
|
+
for relative in ("hooks/hooks.json", "hooks/inject-context.mjs"):
|
|
988
988
|
if not (install_path / relative).is_file():
|
|
989
989
|
return _qoder_warning(
|
|
990
990
|
"PLUGIN-PAYLOAD-INCOMPLETE",
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Qoder plugin hook entry: locate the project runtime and forward the payload.
|
|
3
|
-
|
|
4
|
-
Installed into the Qoder plugin cache, so this file is never next to the
|
|
5
|
-
project it serves. The project is therefore resolved from the hook payload
|
|
6
|
-
(`cwd`) rather than from a module-relative path, and the renderers stay in the
|
|
7
|
-
project's own `.cowork-flow/scripts` — one copy of the injection logic per
|
|
8
|
-
repository, never a stale second copy in the plugin cache.
|
|
9
|
-
|
|
10
|
-
Fail-open by design: outside a cowork-flow project, or when the project's own
|
|
11
|
-
runtime fails, this exits 0 with no injection. The hook is machine-level, so it
|
|
12
|
-
meets project runtimes older than itself; Qoder refuses the prompt on a
|
|
13
|
-
non-zero hook exit, and a stale project copy must never do that.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import json
|
|
19
|
-
import os
|
|
20
|
-
import subprocess
|
|
21
|
-
import sys
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
|
|
24
|
-
HOST = "qoder"
|
|
25
|
-
WORKFLOW_DIR = ".cowork-flow"
|
|
26
|
-
INJECT_RELPATH = ("scripts", "adapters", "host", "inject.py")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _payload_cwd(raw: bytes) -> str | None:
|
|
30
|
-
if not raw.strip():
|
|
31
|
-
return None
|
|
32
|
-
try:
|
|
33
|
-
payload = json.loads(raw.decode("utf-8-sig"))
|
|
34
|
-
except (UnicodeDecodeError, ValueError):
|
|
35
|
-
return None
|
|
36
|
-
if isinstance(payload, dict):
|
|
37
|
-
cwd = payload.get("cwd")
|
|
38
|
-
if isinstance(cwd, str) and cwd.strip():
|
|
39
|
-
return cwd
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def find_workflow_root(start: str | None) -> Path | None:
|
|
44
|
-
if not start:
|
|
45
|
-
return None
|
|
46
|
-
try:
|
|
47
|
-
current = Path(start).resolve()
|
|
48
|
-
except OSError:
|
|
49
|
-
return None
|
|
50
|
-
while True:
|
|
51
|
-
if (current / WORKFLOW_DIR).is_dir():
|
|
52
|
-
return current
|
|
53
|
-
parent = current.parent
|
|
54
|
-
if parent == current:
|
|
55
|
-
return None
|
|
56
|
-
current = parent
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def resolve_root(raw: bytes) -> Path | None:
|
|
60
|
-
candidates = [
|
|
61
|
-
_payload_cwd(raw),
|
|
62
|
-
os.environ.get("QODER_PROJECT_DIR"),
|
|
63
|
-
os.environ.get("QODER_WORKING_DIR"),
|
|
64
|
-
os.getcwd(),
|
|
65
|
-
]
|
|
66
|
-
for candidate in candidates:
|
|
67
|
-
root = find_workflow_root(candidate)
|
|
68
|
-
if root is not None:
|
|
69
|
-
return root
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def resolve_inject(root: Path) -> Path | None:
|
|
74
|
-
inject = root.joinpath(WORKFLOW_DIR, *INJECT_RELPATH)
|
|
75
|
-
return inject if inject.is_file() else None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def main() -> int:
|
|
79
|
-
if (
|
|
80
|
-
os.environ.get("COWORK_FLOW_HOOKS") == "0"
|
|
81
|
-
or os.environ.get("COWORK_FLOW_DISABLE_HOOKS") == "1"
|
|
82
|
-
):
|
|
83
|
-
return 0
|
|
84
|
-
|
|
85
|
-
raw = sys.stdin.buffer.read()
|
|
86
|
-
root = resolve_root(raw)
|
|
87
|
-
if root is None:
|
|
88
|
-
return 0
|
|
89
|
-
inject = resolve_inject(root)
|
|
90
|
-
if inject is None:
|
|
91
|
-
return 0
|
|
92
|
-
|
|
93
|
-
# The hook is already running under a Python 3 interpreter, so the only
|
|
94
|
-
# reason to override it is an explicit COWORK_FLOW_PYTHON; sys.executable
|
|
95
|
-
# keeps the hook working where `python` is not on PATH at all.
|
|
96
|
-
interpreter = (os.environ.get("COWORK_FLOW_PYTHON") or "").strip() or sys.executable
|
|
97
|
-
try:
|
|
98
|
-
completed = subprocess.run(
|
|
99
|
-
[interpreter, str(inject), "--host", HOST],
|
|
100
|
-
input=raw,
|
|
101
|
-
cwd=str(root),
|
|
102
|
-
timeout=25,
|
|
103
|
-
)
|
|
104
|
-
except OSError as error:
|
|
105
|
-
print(f"cowork-flow qoder hook: {error}", file=sys.stderr)
|
|
106
|
-
return 0
|
|
107
|
-
except subprocess.TimeoutExpired:
|
|
108
|
-
print("cowork-flow qoder hook: inject timed out", file=sys.stderr)
|
|
109
|
-
return 0
|
|
110
|
-
if completed.returncode != 0:
|
|
111
|
-
# Never propagate: Qoder counts a non-zero exit on SessionStart and
|
|
112
|
-
# UserPromptSubmit as a blocking hook failure. Keep the reason in the
|
|
113
|
-
# run log and leave the event alone.
|
|
114
|
-
print(
|
|
115
|
-
f"cowork-flow qoder hook: inject.py exited {completed.returncode}; skipping injection",
|
|
116
|
-
file=sys.stderr,
|
|
117
|
-
)
|
|
118
|
-
return 0
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if __name__ == "__main__":
|
|
122
|
-
raise SystemExit(main())
|