pi-crew 0.2.1 → 0.2.3

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.
Files changed (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/docs/code-review-2026-05-11.md +592 -0
  4. package/docs/followup-plan-2026-05-12.md +463 -0
  5. package/docs/followup-review-2026-05-12.md +297 -0
  6. package/docs/followup-review-round3-2026-05-12.md +342 -0
  7. package/package.json +3 -2
  8. package/src/extension/cross-extension-rpc.ts +1 -0
  9. package/src/extension/registration/subagent-tools.ts +1 -0
  10. package/src/extension/registration/team-tool.ts +1 -0
  11. package/src/extension/team-manager-command.ts +1 -0
  12. package/src/extension/team-tool/run.ts +1 -0
  13. package/src/extension/team-tool.ts +344 -332
  14. package/src/runtime/async-runner.ts +89 -15
  15. package/src/runtime/background-runner.ts +1 -0
  16. package/src/runtime/child-pi.ts +2 -4
  17. package/src/runtime/iteration-hooks.ts +5 -2
  18. package/src/runtime/live-session-runtime.ts +1 -0
  19. package/src/runtime/post-checks.ts +5 -2
  20. package/src/runtime/runtime-resolver.ts +1 -0
  21. package/src/runtime/subagent-manager.ts +5 -0
  22. package/src/runtime/task-runner.ts +1 -0
  23. package/src/runtime/yield-handler.ts +1 -0
  24. package/src/schema/team-tool-schema.ts +1 -0
  25. package/src/state/artifact-store.ts +2 -2
  26. package/src/state/atomic-write.ts +21 -4
  27. package/src/state/event-log.ts +110 -47
  28. package/src/state/locks.ts +12 -14
  29. package/src/ui/run-action-dispatcher.ts +1 -0
  30. package/src/utils/env-filter.ts +30 -0
  31. package/src/utils/redaction.ts +1 -1
  32. package/src/utils/resolve-shell.ts +34 -0
  33. package/src/utils/sleep.ts +2 -1
  34. package/src/worktree/cleanup.ts +5 -2
  35. package/src/worktree/worktree-manager.ts +47 -5
@@ -0,0 +1,297 @@
1
+ # Follow-up Review — pi-crew (2026-05-12, round 2)
2
+
3
+ Tác giả: Droid (Factory) | Liên quan: `docs/followup-plan-2026-05-12.md`, commit `926e6ee`.
4
+
5
+ Review lại sau khi commit `926e6ee` đã apply các fix B1–B9 + A1–A2 từ `followup-plan-2026-05-12.md`.
6
+
7
+ ## Tóm tắt kết quả
8
+
9
+ - `npm run typecheck` → Passed
10
+ - `npm run test:unit` → **1411 tests / 1408 pass / 0 fail / 3 skip** (trước: 1389/1400 với 8 fail bash-on-Windows)
11
+ - `npm run check:lazy-imports` → **FAIL trên Windows** (chi tiết bên dưới)
12
+
13
+ ## Trạng thái từng item
14
+
15
+ | # | Item | Trạng thái | Ghi chú |
16
+ |---|---|---|---|
17
+ | B1 | bash portability | ✅ Done | `resolveShellForScript` + `resolveBashCmd` trong `src/utils/resolve-shell.ts`. Áp dụng `post-checks.ts` + `iteration-hooks.ts`. 8 test fail trước đây pass. |
18
+ | B2 | `worktree-manager.test.ts` | ⚠️ Partial | Có 3 test (branch recovery, reuse, clean leader). Thiếu test `linkNodeModulesIfPresent` reject file. |
19
+ | B3 | `artifact-store.test.ts` | ✅ Done | Hash integrity + path traversal + nested dirs. |
20
+ | B4 | lock parity test | ✅ Done | 2 test mới: stale recovery sync+async, active lock throws. |
21
+ | B5 | setup-hook env filter | ⚠️ Partial | `sanitizeEnvSecrets` đã apply. Dùng deny-list `SECRET_KEY_PATTERN` thay vì allow-list như plan đề xuất. |
22
+ | B6 | worktree checked-out hint | ✅ Done | try/catch + actionable error message. |
23
+ | B7 | LAZY marker | ✅ Done | Marker tại `team-tool.ts:58`. |
24
+ | B8 | redaction roundtrip | ✅ Done | 3 test (api_key, bearer, on-disk). |
25
+ | B9 | CI grep-check | ❌ Broken on Windows | Script dùng `sed`, fail trên Windows. |
26
+ | A1 | branchExists remote-tracking | ✅ Done | Trả `{ local, remoteOnly }`. |
27
+ | A2 | jiti fallback robust | ✅ Done | 3 candidates: `lib/jiti-register.mjs`, `register.mjs`, `dist/register.mjs`. |
28
+ | A3 | test alias cleanup | ⏭️ Skipped | Plan ghi "không khẩn cấp". |
29
+
30
+ ---
31
+
32
+ ## Vấn đề cần xử lý tiếp
33
+
34
+ ### C1 — (Medium, ~10 phút) `scripts/check-lazy-imports.mjs` không chạy được trên Windows
35
+
36
+ **File:** `scripts/check-lazy-imports.mjs`
37
+
38
+ **Vấn đề:**
39
+ - Script dùng `execSync("sed -n '...' ...")` để đọc dòng trước → `sed` không có trên Windows mặc định.
40
+ - Khi sed fail, mỗi line đi vào `catch` block → `bad.push(line)` → false positive 13 mục.
41
+ - `npm run ci` sẽ luôn fail trên Windows dev local.
42
+
43
+ **Fix đề xuất:** dùng Node thuần thay sed.
44
+
45
+ ```js
46
+ import { execSync } from "node:child_process";
47
+ import { readFileSync } from "node:fs";
48
+
49
+ const out = execSync(`git grep -nE "await import\\(" -- "src/**/*.ts"`, { encoding: "utf-8" });
50
+ const bad = [];
51
+ const fileCache = new Map();
52
+
53
+ for (const line of out.split("\n").filter(Boolean)) {
54
+ if (line.includes("// LAZY:")) continue;
55
+ const m = line.match(/^([^:]+):(\d+):/);
56
+ if (!m) continue;
57
+ const [, file, lineNum] = m;
58
+ if (!fileCache.has(file)) fileCache.set(file, readFileSync(file, "utf-8").split(/\r?\n/));
59
+ const lines = fileCache.get(file);
60
+ const prevLine = lines[Number(lineNum) - 2] ?? "";
61
+ if (!prevLine.includes("// LAZY:")) bad.push(line);
62
+ }
63
+
64
+ if (bad.length) {
65
+ console.error("Dynamic imports without `// LAZY:` marker:\n" + bad.join("\n"));
66
+ process.exit(1);
67
+ }
68
+ console.log("All dynamic imports have `// LAZY:` marker.");
69
+ ```
70
+
71
+ **Test:** chạy `npm run check:lazy-imports` trên cả Linux và Windows → cả 2 expect "All dynamic imports have `// LAZY:` marker."
72
+
73
+ ---
74
+
75
+ ### C2 — (Medium, ~20 phút) `sanitizeEnvSecrets` dùng deny-list, không đạt mục tiêu defense-in-depth của plan B5
76
+
77
+ **File:** `src/utils/env-filter.ts`
78
+
79
+ **Hiện trạng:**
80
+ ```ts
81
+ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv): Record<string, string> {
82
+ const filtered: Record<string, string> = {};
83
+ for (const [key, value] of Object.entries(env)) {
84
+ if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filtered[key] = value;
85
+ }
86
+ return filtered;
87
+ }
88
+ ```
89
+
90
+ **Vấn đề:**
91
+ - Deny-list chỉ chặn key matching `SECRET_KEY_PATTERN`. Biến có tên không khớp (vd. `DB_PASS`, `MY_KEY_FOO`, `INTERNAL_TOKEN_LEGACY`) sẽ rò sang setup hook.
92
+ - Plan B5 nguyên thuỷ đề xuất allow-list `["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "LANG", "PI_*"]` → an toàn hơn nhiều cho user-provided hooks.
93
+
94
+ **Fix đề xuất:** thêm overload với allow-list, giữ deny-list mặc định cho `buildChildPiSpawnOptions` (backward-compat).
95
+
96
+ ```ts
97
+ export interface SanitizeOptions {
98
+ allowList?: string[]; // glob-like, hỗ trợ * cuối (vd. "PI_*")
99
+ }
100
+
101
+ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeOptions): Record<string, string> {
102
+ const filtered: Record<string, string> = {};
103
+ if (options?.allowList && options.allowList.length > 0) {
104
+ const matchers = options.allowList.map((p) => {
105
+ if (p.endsWith("*")) return (k: string) => k.startsWith(p.slice(0, -1));
106
+ return (k: string) => k === p;
107
+ });
108
+ for (const [key, value] of Object.entries(env)) {
109
+ if (value !== undefined && matchers.some((fn) => fn(key))) filtered[key] = value;
110
+ }
111
+ return filtered;
112
+ }
113
+ for (const [key, value] of Object.entries(env)) {
114
+ if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filtered[key] = value;
115
+ }
116
+ return filtered;
117
+ }
118
+ ```
119
+
120
+ **Apply tại `worktree-manager.ts:runSetupHook`:**
121
+
122
+ ```ts
123
+ env: sanitizeEnvSecrets(process.env, {
124
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
125
+ }),
126
+ ```
127
+
128
+ **Test cần thêm:** `test/unit/env-filter.test.ts`:
129
+ - Allow-list pass-through cho key match + reject còn lại.
130
+ - Glob `PI_*` match `PI_HOME`, `PI_CREW_X` nhưng không match `PIPELINE`.
131
+ - Default deny-list giữ behaviour cũ.
132
+
133
+ ---
134
+
135
+ ### C3 — (Low, ~10 phút) `resolveShellForScript` chưa xử lý đúng `.cmd/.bat` trên Windows
136
+
137
+ **File:** `src/utils/resolve-shell.ts`
138
+
139
+ **Hiện trạng:**
140
+ ```ts
141
+ if (scriptPath.endsWith(".cmd") || scriptPath.endsWith(".bat")) {
142
+ return { command: scriptPath, args: [] };
143
+ }
144
+ ```
145
+
146
+ **Vấn đề:**
147
+ - Node ≥ 20 chặn spawn trực tiếp `.bat/.cmd` mà không có `shell: true` (CVE-2024-27980). Đường code `execFileSync/spawn` sẽ throw `EINVAL` hoặc `ENOENT`.
148
+ - Hệ quả: post-check / iteration-hook viết bằng `.cmd/.bat` sẽ fail âm thầm trên Node 20+.
149
+
150
+ **Fix đề xuất:**
151
+ ```ts
152
+ if (scriptPath.endsWith(".cmd") || scriptPath.endsWith(".bat")) {
153
+ return { command: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", scriptPath] };
154
+ }
155
+ ```
156
+
157
+ **Test cần thêm:** trong `test/unit/resolve-shell.test.ts` (tạo mới):
158
+ - Linux: `.sh` → `{ bash, [path] }`.
159
+ - Windows + `.ps1` → `{ powershell, ["-File", path] }`.
160
+ - Windows + `.cmd` → `{ cmd.exe, ["/d", "/s", "/c", path] }`.
161
+
162
+ ---
163
+
164
+ ### C4 — (Low, ~15 phút) Thiếu test `linkNodeModulesIfPresent` reject file source (BUG-006 regression guard)
165
+
166
+ **File:** `test/unit/worktree-manager.test.ts`
167
+
168
+ **Vấn đề:**
169
+ - Plan B2 yêu cầu test này nhưng commit chưa thêm. Nếu ai sửa lại `linkNodeModulesIfPresent` mà bỏ check `isDirectory()`, BUG-006 sẽ regression mà không bị test bắt.
170
+
171
+ **Fix đề xuất:** thêm 1 test (giả định `linkNodeModulesIfPresent` được export hoặc test gián tiếp qua `prepareTaskWorkspace` với `worktree.linkNodeModules=true`):
172
+
173
+ ```ts
174
+ test("prepareTaskWorkspace skips linkNodeModules when source is a file", () => {
175
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-wt-fn-"));
176
+ initGitRepo(repo);
177
+ // Place a FILE at node_modules instead of a directory
178
+ fs.writeFileSync(path.join(repo, "node_modules"), "not a dir", "utf-8");
179
+ // Write project config to enable linkNodeModules
180
+ const cfgDir = path.join(repo, ".crew");
181
+ fs.mkdirSync(cfgDir, { recursive: true });
182
+ fs.writeFileSync(path.join(cfgDir, "config.json"), JSON.stringify({
183
+ worktree: { linkNodeModules: true },
184
+ }), "utf-8");
185
+ const manifest = minimalManifest(repo, "run-fn");
186
+ const task = minimalTask("task-fn", repo);
187
+ const result = prepareTaskWorkspace(manifest, task);
188
+ assert.equal(result.nodeModulesLinked, false);
189
+ fs.rmSync(repo, { recursive: true, force: true });
190
+ });
191
+ ```
192
+
193
+ ---
194
+
195
+ ### C5 — (Low, ~5 phút) `prepareTaskWorkspace` error message bị ảnh hưởng locale
196
+
197
+ **File:** `src/worktree/worktree-manager.ts:127-140` (try/catch quanh `worktree add`)
198
+
199
+ **Vấn đề:**
200
+ - Regex `/already checked out/` chỉ match khi git chạy với English locale. Trên máy user có `LANG=vi_VN` hoặc Git for Windows với locale khác, message gốc khác → fallback throw error raw.
201
+
202
+ **Fix đề xuất:** force English locale cho git command nội bộ, hoặc mở rộng regex.
203
+
204
+ Option A (đơn giản hơn): mở rộng regex
205
+ ```ts
206
+ if (/already checked out|is already used by worktree|đã được/i.test(msg)) { ... }
207
+ ```
208
+
209
+ Option B (kiến nghị): force LANG=C trong helper `git()`:
210
+ ```ts
211
+ function git(cwd: string, args: string[]): string {
212
+ return execFileSync("git", args, {
213
+ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
214
+ env: { ...process.env, LANG: "C", LC_ALL: "C" },
215
+ }).trim();
216
+ }
217
+ ```
218
+
219
+ Option B chuẩn hoá toàn bộ output git → cải thiện cả debugging.
220
+
221
+ ---
222
+
223
+ ### C6 — (Info, ~5 phút) `branchExists` remote-only tạo local từ HEAD, có thể "mất" commits của remote
224
+
225
+ **File:** `src/worktree/worktree-manager.ts:prepareTaskWorkspace`
226
+
227
+ **Hiện trạng:**
228
+ ```ts
229
+ if (exists.local) {
230
+ git(repoRoot, ["worktree", "add", worktreePath, branch]);
231
+ } else {
232
+ git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]); // ← cả remoteOnly và "không tồn tại" đều rơi vào đây
233
+ }
234
+ ```
235
+
236
+ **Vấn đề:**
237
+ - Khi `remoteOnly === true`, plan A1 đề xuất tạo local từ HEAD để tránh divergent tracking. Code hiện tại làm đúng vậy, nhưng:
238
+ - Người dùng push branch từ máy khác → expect worktree chứa code đó.
239
+ - Không có log/warning → silent drop.
240
+
241
+ **Fix đề xuất:** emit info event/log khi rơi vào nhánh remoteOnly:
242
+ ```ts
243
+ } else {
244
+ if (exists.remoteOnly) {
245
+ logInternalError("worktree.branchRemoteOnly", new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`), `branch=${branch}`);
246
+ }
247
+ git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
248
+ }
249
+ ```
250
+
251
+ (Hoặc đẩy lên qua run event nếu có event bus thuận tiện.)
252
+
253
+ ---
254
+
255
+ ## Ưu tiên thực hiện
256
+
257
+ | # | Item | Severity | Effort | Khuyến nghị |
258
+ |---|---|---|---|---|
259
+ | 1 | C1 (lazy-imports cross-platform) | Medium | 10 phút | Sprint hiện tại — chặn CI fail trên Windows |
260
+ | 2 | C2 (allow-list env filter) | Medium | 20 phút | Sprint hiện tại — defense-in-depth |
261
+ | 3 | C3 (.cmd/.bat trên Node 20+) | Low | 10 phút | Sprint hiện tại — đảm bảo B1 thực sự portable |
262
+ | 4 | C4 (test linkNodeModules file source) | Low | 15 phút | Sprint hiện tại — đóng gap regression của B2 |
263
+ | 5 | C5 (git locale-safe error parsing) | Low | 5 phút | Sprint kế tiếp |
264
+ | 6 | C6 (warn khi remote-only branch) | Info | 5 phút | Sprint kế tiếp |
265
+
266
+ **Tổng effort:** ~65 phút cho batch hardening.
267
+
268
+ ---
269
+
270
+ ## Đề xuất commit batches
271
+
272
+ - **Batch 1 (must-fix CI):** C1 + C3 → 1 PR "scripts/cmd portability fix" (~20 phút).
273
+ - **Batch 2 (security/test):** C2 + C4 → 1 PR "env allow-list + worktree regression test" (~35 phút).
274
+ - **Batch 3 (polish):** C5 + C6 → 1 PR "git locale + remote-only branch hint" (~10 phút).
275
+
276
+ ---
277
+
278
+ ## Điểm tích cực sau review lần 3
279
+
280
+ - 8 bash-on-Windows test fail trước đây giờ pass 100%.
281
+ - DRY refactor `sanitizeEnvSecrets` (extract từ `child-pi.ts`) tốt.
282
+ - `worktree-manager` resume logic + actionable error rất hữu ích cho UX.
283
+ - Lock parity test cover cả 2 path sync/async (chính xác mục tiêu BUG-004).
284
+ - Artifact hash test verify đúng invariant `sha256(file) == contentHash`.
285
+ - `resolveJitiRegisterPath` giờ chịu được nhiều packaging layout của jiti.
286
+ - `branchExists` upgrade `{local, remoteOnly}` chính xác theo plan A1.
287
+
288
+ ---
289
+
290
+ ## Verification
291
+
292
+ ```
293
+ npm run typecheck → Passed
294
+ npm run check:lazy-imports → Fails on Windows (sed not found)
295
+ npm run test:unit → 1411 tests, 1408 pass, 0 fail, 3 skip (227s)
296
+ git show 926e6ee --stat → 16 files changed, 797(+) 22(-)
297
+ ```
@@ -0,0 +1,342 @@
1
+ # Follow-up Review — pi-crew (2026-05-12, round 3)
2
+
3
+ Tác giả: Droid (Factory) | Liên quan: `docs/code-review-2026-05-11.md`, `docs/followup-plan-2026-05-12.md`, `docs/followup-review-2026-05-12.md`. HEAD: `5bee878`.
4
+
5
+ Đây là vòng review sau khi commit `5bee878` đã giải quyết C1–C6. Mục tiêu: rà soát các module chưa được soi kỹ (event-log, atomic-write, child-pi, redaction, sleep, hooks, cleanup) để tìm rủi ro còn lại.
6
+
7
+ ## Tóm tắt kết quả
8
+
9
+ - `npm run typecheck` → Passed
10
+ - `npm run check:lazy-imports` → Passed
11
+ - `npm run test:unit` → **1418 tests / 1415 pass / 0 fail / 3 skip** (212s)
12
+
13
+ Codebase ở trạng thái ổn định. Các phát hiện dưới đây là **risk thấp** hoặc **defense-in-depth**, không phải bug khẩn.
14
+
15
+ ---
16
+
17
+ ## Phần A — Phát hiện mới
18
+
19
+ ### D1 — `event-log.appendEvent` không lock, JSONL có thể interleave trên Windows
20
+
21
+ **Severity:** Medium | **Effort:** ~30 phút | **File:** `src/state/event-log.ts:148`
22
+
23
+ **Hiện trạng:**
24
+ ```ts
25
+ fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
26
+ ```
27
+
28
+ **Vấn đề:**
29
+ - `fs.appendFileSync` trên POSIX chỉ atomic cho write nhỏ hơn `PIPE_BUF` (~4 KiB). Event JSON đầy đủ (có data, metadata, transcripts) có thể vượt ngưỡng → interleave dòng giữa 2 process (parent + background-runner).
30
+ - Trên Windows, append KHÔNG atomic cho mọi kích thước; 2 process append cùng eventsPath có thể tạo dòng JSON xen lẫn → `JSON.parse(line)` ở `readEvents`/`scanSequence` throw và bỏ qua dòng.
31
+ - Hệ quả: mất event, sequence số tăng nhảy, "appended: false" được trả về trong path khác (size-limit) nhưng path bình thường không có hint.
32
+
33
+ **Trigger:** chạy `background-runner` song song với parent ghi event trên cùng `eventsPath` (vd. cancel + retry liên tục).
34
+
35
+ **Fix đề xuất:**
36
+ 1. Wrap `appendEvent` trong `withRunLockSync(manifest, () => { ... })` — đảm bảo exclusive access.
37
+ 2. Hoặc dùng `fs.openSync(..., O_APPEND | O_WRONLY)` + retry với advisory lock (`flock` POSIX, `LockFileEx` Windows — qua npm package `proper-lockfile`).
38
+ 3. Phương án nhẹ nhất: chuyển sang `appendEventAsync` qua queue/serialize.
39
+
40
+ **Test cần thêm:** stress test ở `test/integration/`: 2 process append đồng thời 100 events mỗi bên → assert tổng số dòng parse OK = 200.
41
+
42
+ ---
43
+
44
+ ### D2 — `event-log.sequenceCache` Map leak theo số lượng runs
45
+
46
+ **Severity:** Low | **Effort:** ~10 phút | **File:** `src/state/event-log.ts:60`
47
+
48
+ **Hiện trạng:**
49
+ ```ts
50
+ const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
51
+ ```
52
+
53
+ **Vấn đề:**
54
+ - Module-level map, không bao giờ evict. Mỗi `eventsPath` (1 per run) chiếm 1 entry. Long-running parent process (vd. live-session-runtime) duy trì nhiều ngày → cache có thể tới hàng nghìn entries.
55
+ - Memory không lớn (~100 bytes/entry) nhưng vô hạn.
56
+
57
+ **Fix đề xuất:** dùng LRU đơn giản (Map có max size, evict oldest khi vượt ngưỡng), hoặc clear sau khi run kết thúc:
58
+ ```ts
59
+ export function evictSequenceCache(eventsPath: string): void {
60
+ sequenceCache.delete(eventsPath);
61
+ }
62
+ // Call from updateRunStatus(..., "completed"/"failed"/"cancelled").
63
+ ```
64
+
65
+ ---
66
+
67
+ ### D3 — `atomicWriteFileAsync` có fallback "matches" → sync không có (parity)
68
+
69
+ **Severity:** Low | **Effort:** ~15 phút | **File:** `src/state/atomic-write.ts:122-138`
70
+
71
+ **Hiện trạng:**
72
+ ```ts
73
+ // async path:
74
+ try { await renameWithRetryAsync(...); }
75
+ catch (renameError) {
76
+ const existing = await fs.promises.readFile(filePath, "utf-8");
77
+ const matches = existing === content;
78
+ if (matches) { /* cleanup temp, return success */ }
79
+ throw renameError;
80
+ }
81
+
82
+ // sync path: chỉ throw, không có fallback "matches".
83
+ ```
84
+
85
+ **Vấn đề:**
86
+ - Async path "tha thứ" cho race condition (file đã được ghi đúng content bởi process khác). Sync path thì throw cứng.
87
+ - Ngữ nghĩa khác nhau → khó debug khi có ai dùng sync với race.
88
+ - Trường hợp này hiếm (cùng content), nhưng asymmetry là code smell.
89
+
90
+ **Fix đề xuất:** thêm cùng fallback cho sync, hoặc xoá fallback khỏi async (chọn 1 quy ước nhất quán):
91
+ ```ts
92
+ } catch (renameError) {
93
+ try {
94
+ const existing = fs.readFileSync(filePath, "utf-8");
95
+ if (existing === content) {
96
+ try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
97
+ return;
98
+ }
99
+ } catch { /* fall through */ }
100
+ throw renameError;
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ### D4 — `withRunLock` (async) chờ deadline cho active lock, `withRunLockSync` throw ngay
107
+
108
+ **Severity:** Low | **Effort:** ~10 phút | **File:** `src/state/locks.ts:91-110`
109
+
110
+ **Hiện trạng:**
111
+ - Sync: `if (!isLockStale(...)) throw ...` → fail fast cho active lock.
112
+ - Async: chỉ check stale trong `readLockStateAsync`, không throw cho active → loop chờ tới deadline (`staleMs * 2`, thường 60s).
113
+
114
+ **Vấn đề:**
115
+ - Test `withRunLockSync throws immediately on active (non-stale) lock` đã chứng minh sync throw ngay.
116
+ - Async sẽ hang ~60s rồi mới throw → trải nghiệm cancel/retry chậm.
117
+ - BUG-004 (round 1) đặt mục tiêu unify sync ↔ async, nhưng vẫn còn asymmetry semantic này.
118
+
119
+ **Fix đề xuất:** thống nhất theo 1 trong 2:
120
+ - Sync: thêm short wait + retry tương tự async (chờ tối đa 1-2s rồi throw).
121
+ - Async: throw ngay khi lock không stale (giống sync) — thường tốt hơn vì caller có thể tự retry với context cao hơn.
122
+
123
+ ```ts
124
+ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Promise<void> {
125
+ let attempt = 0;
126
+ const deadline = Date.now() + staleMs * 2;
127
+ while (true) {
128
+ try { writeLockFile(filePath); return; }
129
+ catch (error) {
130
+ const code = (error as NodeJS.ErrnoException).code;
131
+ if (code !== "EEXIST") throw error;
132
+ if (Date.now() > deadline) throw new Error(`Run '${path.basename(filePath)}' is locked.`);
133
+ if (!isLockStale(filePath, staleMs)) {
134
+ throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
135
+ }
136
+ try { fs.rmSync(filePath, { force: true }); } catch { /* race */ }
137
+ await sleep(Math.min(250, 25 * 2 ** attempt));
138
+ attempt++;
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ **Test cần thêm:** mirror test sync — `withRunLock async throws immediately on active (non-stale) lock`.
145
+
146
+ ---
147
+
148
+ ### D5 — `sleep.ts` dùng `require()` trong ES module
149
+
150
+ **Severity:** Low (style) | **Effort:** ~5 phút | **File:** `src/utils/sleep.ts:18`
151
+
152
+ **Hiện trạng:**
153
+ ```ts
154
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
155
+ ```
156
+
157
+ **Vấn đề:**
158
+ - Project là ESM (`"type": "module"`). `require` chỉ work qua strip-types backward-compat — chưa chuẩn.
159
+ - AGENTS.md: "Avoid dynamic inline imports, EXCEPT at documented lazy-load boundaries to defer heavy runtime cost (mark with `// LAZY: <reason>`)". `require` ở đây không có marker.
160
+ - `child_process` không nặng — top-level import OK.
161
+
162
+ **Fix đề xuất:**
163
+ ```ts
164
+ import { execFileSync } from "node:child_process";
165
+ // ...
166
+ execFileSync("sleep", [(ms / 1000).toFixed(3)], { timeout: ms + 1000, stdio: "pipe" });
167
+ ```
168
+
169
+ ---
170
+
171
+ ### D6 — `iteration-hooks.runIterationHook` chưa filter env như post-checks
172
+
173
+ **Severity:** Low | **Effort:** ~5 phút | **File:** `src/runtime/iteration-hooks.ts:140`
174
+
175
+ **Hiện trạng:**
176
+ ```ts
177
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", USER: process.env.USER, LANG: process.env.LANG, PI_CREW_HOOK: "1" },
178
+ ```
179
+
180
+ **Vấn đề:**
181
+ - Đã restrict thủ công, OK với Linux. Nhưng trên Windows thiếu `USERPROFILE`, `TEMP`, `TMP`, `ComSpec`, `SystemRoot` → script `.cmd/.ps1` có thể fail.
182
+ - Post-checks.ts có cùng pattern (line 82) — không nhất quán với worktree-manager.runSetupHook đã chuyển sang `sanitizeEnvSecrets(..., { allowList: [...] })`.
183
+
184
+ **Fix đề xuất:** áp dụng `sanitizeEnvSecrets` với allowList, đồng nhất 3 chỗ (post-checks, iteration-hooks, setup-hook):
185
+ ```ts
186
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
187
+ const HOOK_ENV_ALLOW = ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"];
188
+ // ...
189
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: HOOK_ENV_ALLOW }), PI_CREW_HOOK: "1" },
190
+ ```
191
+
192
+ **Lợi ích:**
193
+ - 1 nguồn truth cho whitelist env hook.
194
+ - Hỗ trợ Windows .cmd/.ps1 (USERPROFILE/TEMP cần thiết).
195
+ - Tránh lặp code.
196
+
197
+ ---
198
+
199
+ ### D7 — `cleanup.ts` git helper không force locale (consistency với worktree-manager)
200
+
201
+ **Severity:** Info | **Effort:** ~2 phút | **File:** `src/worktree/cleanup.ts:15`
202
+
203
+ **Hiện trạng:**
204
+ ```ts
205
+ function git(cwd: string, args: string[]): string {
206
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
207
+ }
208
+ ```
209
+
210
+ **Vấn đề:**
211
+ - `worktree-manager.ts` đã force `LANG: "C", LC_ALL: "C"` (C5 round 2). `cleanup.ts` chưa.
212
+ - Không gây bug hiện tại vì cleanup không parse error string; nhưng tương lai nếu thêm error parsing thì lại miss.
213
+ - `branch-freshness.ts` cũng cùng vấn đề.
214
+
215
+ **Fix đề xuất:** extract `git()` helper vào `src/utils/git-helper.ts` chung, dùng ở cả 3 file:
216
+ ```ts
217
+ // src/utils/git-exec.ts
218
+ import { execFileSync } from "node:child_process";
219
+ export function gitExec(cwd: string, args: string[]): string {
220
+ return execFileSync("git", args, {
221
+ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
222
+ env: { ...process.env, LANG: "C", LC_ALL: "C" },
223
+ }).trim();
224
+ }
225
+ ```
226
+
227
+ ---
228
+
229
+ ### D8 — `redaction.PEM_PRIVATE_KEY_PATTERN` không giới hạn độ dài → tiềm năng ReDoS thấp
230
+
231
+ **Severity:** Info | **Effort:** ~5 phút | **File:** `src/utils/redaction.ts:7`
232
+
233
+ **Hiện trạng:**
234
+ ```ts
235
+ const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
236
+ ```
237
+
238
+ **Vấn đề:**
239
+ - Lazy `[\s\S]*?` an toàn về ReDoS, nhưng nếu input có 1 BEGIN mà không có END → backtrack tới hết string. Với JSONL transcript dài (10+ MB), regex sẽ scan toàn bộ.
240
+ - Không phải ReDoS thực sự (linear), nhưng có thể chậm.
241
+
242
+ **Fix đề xuất:** thêm hard limit 64KB cho block PEM (PEM thực tế ~3KB):
243
+ ```ts
244
+ const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,65536}?-----END [A-Z ]*PRIVATE KEY-----/g;
245
+ ```
246
+
247
+ Trade-off: PEM > 64KB không được redact đầy đủ. Hiếm trong thực tế.
248
+
249
+ ---
250
+
251
+ ### D9 — `subagent-manager.persistedSubagentPath` không validate `id` → path traversal tiềm năng
252
+
253
+ **Severity:** Low | **Effort:** ~5 phút | **File:** `src/runtime/subagent-manager.ts:58`
254
+
255
+ **Hiện trạng:**
256
+ ```ts
257
+ function persistedSubagentPath(cwd: string, id: string): string {
258
+ return path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir, `${id}.json`);
259
+ }
260
+ ```
261
+
262
+ **Vấn đề:**
263
+ - `id` đang được sinh nội bộ (`agent_${Date.now().toString(36)}_${counter.toString(36)}`) → an toàn.
264
+ - Nhưng `readPersistedSubagentRecord(cwd, id)` được gọi với `id` từ external source (vd. `get_subagent_result` tool param). Nếu validation tool param thiếu, `id = "../../../etc/passwd"` có thể đọc file ngoài state dir.
265
+
266
+ **Fix đề xuất:** validate `id` matches `^[a-z0-9_]+$`:
267
+ ```ts
268
+ function isValidSubagentId(id: string): boolean {
269
+ return /^[a-z0-9_]+$/i.test(id) && id.length <= 128;
270
+ }
271
+ function persistedSubagentPath(cwd: string, id: string): string {
272
+ if (!isValidSubagentId(id)) throw new Error(`Invalid subagent id: ${id}`);
273
+ return path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir, `${id}.json`);
274
+ }
275
+ ```
276
+
277
+ Kiểm tra schema tool `get_subagent_result` để xem có sanitize id chưa; nếu có thì D9 chỉ là defense-in-depth.
278
+
279
+ ---
280
+
281
+ ## Ưu tiên thực hiện
282
+
283
+ | # | Item | Severity | Effort | Khuyến nghị |
284
+ |---|---|---|---|---|
285
+ | 1 | D1 (event-log concurrent append) | Medium | 30 phút | Sprint hiện tại — chống event loss/corruption |
286
+ | 2 | D6 (hook env allowList nhất quán) | Low | 5 phút | Sprint hiện tại — đồng bộ với setup-hook fix |
287
+ | 3 | D4 (async lock fail-fast cho active) | Low | 10 phút | Sprint hiện tại — UX cancel/retry |
288
+ | 4 | D9 (subagent id validate) | Low | 5 phút | Sprint hiện tại — defense-in-depth |
289
+ | 5 | D2 (sequenceCache eviction) | Low | 10 phút | Sprint kế tiếp |
290
+ | 6 | D3 (atomic-write sync/async parity) | Low | 15 phút | Sprint kế tiếp |
291
+ | 7 | D5 (sleep.ts ESM require) | Low | 5 phút | Sprint kế tiếp |
292
+ | 8 | D7 (git helper consolidate) | Info | 2 phút | Lúc nào cũng được |
293
+ | 9 | D8 (PEM regex limit) | Info | 5 phút | Lúc nào cũng được |
294
+
295
+ **Tổng effort priority 1 (must-fix):** ~50 phút.
296
+ **Tổng effort priority 2 (nice-to-have):** ~37 phút.
297
+
298
+ ---
299
+
300
+ ## Đề xuất commit batches
301
+
302
+ - **Batch 1 (correctness/security):** D1 + D9 + D4 → 1 PR "event-log lock + subagent id guard + async lock parity" (~45 phút).
303
+ - **Batch 2 (hardening):** D6 + D7 + D2 → 1 PR "hook env allowList consolidation + git helper extract + cache eviction" (~20 phút).
304
+ - **Batch 3 (polish):** D3 + D5 + D8 → 1 PR "atomic-write parity + ESM cleanup + redaction limit" (~25 phút).
305
+
306
+ ---
307
+
308
+ ## Điểm tích cực sau round 3
309
+
310
+ - Tất cả C1–C6 (round 2) đã fix đúng theo spec.
311
+ - 1418 tests pass (so với 1411 round trước → +7 test mới), 0 fail.
312
+ - `npm run check:lazy-imports` đã chạy được trên Windows (sau khi loại bỏ `sed`).
313
+ - `sanitizeEnvSecrets` có cả deny-list (default) và allow-list mode → flexibility tốt.
314
+ - `resolveShellForScript` xử lý đúng `.bat/.cmd` chống CVE-2024-27980.
315
+ - `parent-guard` polling tốt cho cross-platform (POSIX + Windows).
316
+ - Redaction pipeline đa lớp (key-name + inline-substring + auth-header + bearer + PEM).
317
+ - Atomic-write có O_EXCL + O_NOFOLLOW + post-open `isFile()` verify.
318
+ - Subagent records persisted dưới redaction filter.
319
+ - Background runner có `parent-guard` + cleanup tempdir + final-drain timer.
320
+
321
+ ---
322
+
323
+ ## Vùng KHÔNG có vấn đề nghiêm trọng (đã rà)
324
+
325
+ - `src/schema/team-tool-schema.ts` — TypeBox schema có đủ literal "retry", strict additionalProperties.
326
+ - `src/state/artifact-store.ts` — path traversal blocking 2 lớp (`resolveInside` + `resolveRealContainedPath`), hash post-redaction.
327
+ - `src/state/atomic-write.ts` — symlink-safe, O_EXCL, fd-based stat verification.
328
+ - `src/worktree/worktree-manager.ts` — branchExists local+remote, prune stale, env filter, locale-safe error parse.
329
+ - `src/runtime/async-runner.ts` — jiti + strip-types fallback, đa candidate path.
330
+ - `src/runtime/child-pi.ts` — env sanitize, redacted transcript, post-exit stdio guard, hard kill timer.
331
+ - `src/runtime/parent-guard.ts` — kill(pid,0) cross-platform, unref'd interval.
332
+
333
+ ---
334
+
335
+ ## Verification
336
+
337
+ ```
338
+ npm run typecheck → Passed
339
+ npm run check:lazy-imports → All dynamic imports have `// LAZY:` marker.
340
+ npm run test:unit → 1418 tests, 1415 pass, 0 fail, 3 skip (212s)
341
+ HEAD → 5bee878 (fix: address followup-review C1-C6)
342
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -44,7 +44,8 @@
44
44
  ],
45
45
  "scripts": {
46
46
  "check": "npm run ci",
47
- "ci": "npm run typecheck && npm test && npm pack --dry-run",
47
+ "ci": "npm run typecheck && npm run check:lazy-imports && npm test && npm pack --dry-run",
48
+ "check:lazy-imports": "node scripts/check-lazy-imports.mjs",
48
49
  "typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
49
50
  "test": "npm run test:unit && npm run test:integration",
50
51
  "test:unit": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=30000 test/unit/*.test.ts",
@@ -5,6 +5,7 @@ import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
5
5
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
6
6
  async function handleTeamTool(params: Parameters<typeof HandleTeamToolFn>[0], ctx: Parameters<typeof HandleTeamToolFn>[1]): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
7
7
  if (!_cachedHandleTeamTool) {
8
+ // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
8
9
  const mod = await import("./team-tool.ts");
9
10
  _cachedHandleTeamTool = mod.handleTeamTool;
10
11
  }
@@ -6,6 +6,7 @@ import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
6
6
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
7
7
  async function handleTeamTool(params: Parameters<typeof HandleTeamToolFn>[0], ctx: Parameters<typeof HandleTeamToolFn>[1]): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
8
8
  if (!_cachedHandleTeamTool) {
9
+ // LAZY: team-tool.ts pulls in entire runtime chain.
9
10
  const mod = await import("../team-tool.ts");
10
11
  _cachedHandleTeamTool = mod.handleTeamTool;
11
12
  }
@@ -14,6 +14,7 @@ import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
14
14
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
15
15
  async function handleTeamTool(params: Parameters<typeof HandleTeamToolFn>[0], ctx: Parameters<typeof HandleTeamToolFn>[1]): Promise<ReturnType<typeof HandleTeamToolFn>> {
16
16
  if (!_cachedHandleTeamTool) {
17
+ // LAZY: team-tool.ts imports many modules — defer until first use.
17
18
  const mod = await import("../team-tool.ts");
18
19
  _cachedHandleTeamTool = mod.handleTeamTool;
19
20
  }