pi-orchestrator-config 4.1.5

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 (175) hide show
  1. package/README.md +808 -0
  2. package/agents/api-documenter.md +32 -0
  3. package/agents/bash-expert.md +53 -0
  4. package/agents/code-reviewer-docs.md +145 -0
  5. package/agents/code-reviewer-guidelines.md +97 -0
  6. package/agents/code-reviewer-quality.md +142 -0
  7. package/agents/code-reviewer-security.md +107 -0
  8. package/agents/code-reviewer-spec.md +164 -0
  9. package/agents/debugger.md +63 -0
  10. package/agents/docker-expert.md +40 -0
  11. package/agents/docs-fetcher.md +48 -0
  12. package/agents/git-expert.md +63 -0
  13. package/agents/github-expert.md +83 -0
  14. package/agents/go-expert.md +62 -0
  15. package/agents/issue-reviewer-feasibility.md +60 -0
  16. package/agents/issue-reviewer-scope.md +63 -0
  17. package/agents/issue-reviewer-spec.md +77 -0
  18. package/agents/java-expert.md +31 -0
  19. package/agents/jenkins-expert.md +38 -0
  20. package/agents/kubernetes-expert.md +40 -0
  21. package/agents/planner.md +42 -0
  22. package/agents/python-expert.md +61 -0
  23. package/agents/reviewer.md +26 -0
  24. package/agents/scout.md +40 -0
  25. package/agents/security-auditor.md +254 -0
  26. package/agents/technical-documentation-writer.md +37 -0
  27. package/agents/test-automator.md +55 -0
  28. package/agents/test-runner.md +47 -0
  29. package/agents/ts-expert.md +23 -0
  30. package/agents/worker.md +30 -0
  31. package/extensions/acpx-provider/index.ts +104 -0
  32. package/extensions/acpx-provider/load-runtime.ts +99 -0
  33. package/extensions/acpx-provider/runtime-models.ts +40 -0
  34. package/extensions/cli-provider/agents/claude.ts +128 -0
  35. package/extensions/cli-provider/agents/cursor.ts +84 -0
  36. package/extensions/cli-provider/agents/gemini.ts +100 -0
  37. package/extensions/cli-provider/discover.ts +77 -0
  38. package/extensions/cli-provider/index.ts +57 -0
  39. package/extensions/cli-provider/parsers.ts +304 -0
  40. package/extensions/cli-provider/providers.ts +58 -0
  41. package/extensions/cli-provider/runner.ts +225 -0
  42. package/extensions/cli-provider/runtime-models.ts +32 -0
  43. package/extensions/cli-provider/session-reaper.ts +122 -0
  44. package/extensions/cli-provider/sessions.ts +533 -0
  45. package/extensions/cli-provider/shared/discover-cache.ts +235 -0
  46. package/extensions/cli-provider/types.ts +48 -0
  47. package/extensions/coms/coms-net-server.ts +1642 -0
  48. package/extensions/coms/coms-net-wrapper.ts +521 -0
  49. package/extensions/coms/coms-net.ts +1589 -0
  50. package/extensions/coms/coms-p2p.ts +1879 -0
  51. package/extensions/coms/coms-shared.ts +510 -0
  52. package/extensions/coms/coms-wrapper.ts +138 -0
  53. package/extensions/coms/index.ts +20 -0
  54. package/extensions/coms/themeMap.ts +147 -0
  55. package/extensions/image-gen/image-gen.ts +332 -0
  56. package/extensions/image-gen/index.ts +14 -0
  57. package/extensions/orchestrator/agents.ts +134 -0
  58. package/extensions/orchestrator/ask-user.ts +293 -0
  59. package/extensions/orchestrator/async-agents.ts +1037 -0
  60. package/extensions/orchestrator/async-capability.ts +129 -0
  61. package/extensions/orchestrator/async-runner.ts +324 -0
  62. package/extensions/orchestrator/async-status-parse.ts +31 -0
  63. package/extensions/orchestrator/async-status-ui.ts +201 -0
  64. package/extensions/orchestrator/async-wait.ts +36 -0
  65. package/extensions/orchestrator/btw.ts +210 -0
  66. package/extensions/orchestrator/cron-status-format.ts +126 -0
  67. package/extensions/orchestrator/cron-status-ui.ts +132 -0
  68. package/extensions/orchestrator/cron.ts +730 -0
  69. package/extensions/orchestrator/dreaming.ts +355 -0
  70. package/extensions/orchestrator/enforcement-helpers.ts +530 -0
  71. package/extensions/orchestrator/enforcement-rules.ts +329 -0
  72. package/extensions/orchestrator/enforcement.ts +828 -0
  73. package/extensions/orchestrator/extended-autocomplete.ts +593 -0
  74. package/extensions/orchestrator/git-helpers.ts +420 -0
  75. package/extensions/orchestrator/github-autocomplete.ts +250 -0
  76. package/extensions/orchestrator/icons.ts +17 -0
  77. package/extensions/orchestrator/index.ts +100 -0
  78. package/extensions/orchestrator/memory-embeddings.ts +281 -0
  79. package/extensions/orchestrator/memory-promotion.ts +449 -0
  80. package/extensions/orchestrator/memory-provenance.ts +80 -0
  81. package/extensions/orchestrator/memory-query-class.ts +90 -0
  82. package/extensions/orchestrator/memory-scoring.ts +434 -0
  83. package/extensions/orchestrator/memory-tools.ts +883 -0
  84. package/extensions/orchestrator/memory-tree.ts +212 -0
  85. package/extensions/orchestrator/nvim.ts +169 -0
  86. package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
  87. package/extensions/orchestrator/overlay-dashboard.ts +512 -0
  88. package/extensions/orchestrator/pi-config-review-state.ts +303 -0
  89. package/extensions/orchestrator/preference-extractor.ts +106 -0
  90. package/extensions/orchestrator/project-settings.ts +468 -0
  91. package/extensions/orchestrator/promotion-queue.ts +311 -0
  92. package/extensions/orchestrator/resolve-agent-model.ts +32 -0
  93. package/extensions/orchestrator/review-ui.ts +204 -0
  94. package/extensions/orchestrator/rule-placeholders.ts +12 -0
  95. package/extensions/orchestrator/rules.ts +481 -0
  96. package/extensions/orchestrator/session-search.ts +243 -0
  97. package/extensions/orchestrator/session-validation.ts +374 -0
  98. package/extensions/orchestrator/situation-report.ts +370 -0
  99. package/extensions/orchestrator/status-bar.ts +31 -0
  100. package/extensions/orchestrator/status-line.ts +192 -0
  101. package/extensions/orchestrator/status.ts +110 -0
  102. package/extensions/orchestrator/subagent-tool.ts +1748 -0
  103. package/extensions/orchestrator/task-lifecycle.ts +88 -0
  104. package/extensions/orchestrator/utils.ts +227 -0
  105. package/extensions/pidash/index.ts +15 -0
  106. package/extensions/pidash/pidash.ts +941 -0
  107. package/extensions/pidiff/index.ts +13 -0
  108. package/extensions/pidiff/pidiff.ts +369 -0
  109. package/extensions/providers/acpx-driver.ts +479 -0
  110. package/extensions/providers/built-in-drivers.ts +52 -0
  111. package/extensions/providers/claude-driver.ts +319 -0
  112. package/extensions/providers/cursor-acpx-driver.ts +398 -0
  113. package/extensions/providers/cursor-cli-driver.ts +484 -0
  114. package/extensions/providers/gemini-driver.ts +302 -0
  115. package/extensions/providers/index.ts +513 -0
  116. package/extensions/shared/build-system-prompt.ts +57 -0
  117. package/extensions/shared/create-runtime-provider.ts +190 -0
  118. package/extensions/shared/daemon-manager.ts +248 -0
  119. package/extensions/shared/file-logger.ts +115 -0
  120. package/extensions/shared/managed-refresh.ts +211 -0
  121. package/extensions/shared/provider-driver.ts +295 -0
  122. package/extensions/shared/provider-errors.ts +305 -0
  123. package/extensions/shared/provider-registry.ts +388 -0
  124. package/extensions/shared/stream-builder.ts +275 -0
  125. package/extensions/shared/types.ts +20 -0
  126. package/extensions/shared/ui/README.md +10 -0
  127. package/extensions/shared/ui/badge.tsx +52 -0
  128. package/extensions/shared/ui/button.tsx +58 -0
  129. package/extensions/shared/ui/collapsible.tsx +21 -0
  130. package/extensions/shared/ui/dropdown-menu.tsx +266 -0
  131. package/extensions/shared/ui/input.tsx +20 -0
  132. package/extensions/shared/ui/popover.tsx +114 -0
  133. package/extensions/shared/ui/resizable.tsx +48 -0
  134. package/extensions/shared/ui/scroll-area.tsx +53 -0
  135. package/extensions/shared/ui/select.tsx +201 -0
  136. package/extensions/shared/ui/separator.tsx +25 -0
  137. package/extensions/shared/ui/tooltip.tsx +64 -0
  138. package/extensions/shared/ws-client.ts +74 -0
  139. package/package.json +59 -0
  140. package/prompts/coderabbit-rate-limit.md +95 -0
  141. package/prompts/create-coms-feature-manager.md +186 -0
  142. package/prompts/create-skill.md +128 -0
  143. package/prompts/domain-model.md +109 -0
  144. package/prompts/external-ai.md +665 -0
  145. package/prompts/implement-and-review.md +25 -0
  146. package/prompts/implement.md +26 -0
  147. package/prompts/issue-review.md +256 -0
  148. package/prompts/pr-review.md +753 -0
  149. package/prompts/query-db.md +154 -0
  150. package/prompts/refine-review.md +208 -0
  151. package/prompts/release.md +235 -0
  152. package/prompts/remember.md +33 -0
  153. package/prompts/review-handler-status.md +27 -0
  154. package/prompts/review-handler.md +677 -0
  155. package/prompts/review-local.md +159 -0
  156. package/prompts/scout-and-plan.md +21 -0
  157. package/rules/00-orchestrator-core.md +42 -0
  158. package/rules/05-issue-first-workflow.md +119 -0
  159. package/rules/10-agent-routing.md +58 -0
  160. package/rules/15-mcp-launchpad.md +46 -0
  161. package/rules/20-code-review-loop.md +234 -0
  162. package/rules/25-documentation-updates.md +15 -0
  163. package/rules/30-prompt-templates.md +19 -0
  164. package/rules/35-memory.md +240 -0
  165. package/rules/40-critical-rules.md +124 -0
  166. package/rules/45-file-preview.md +29 -0
  167. package/rules/50-agent-bug-reporting.md +90 -0
  168. package/rules/55-coms-protocol.md +63 -0
  169. package/rules/60-task-tracking.md +66 -0
  170. package/skills/conflict-resolver/SKILL.md +213 -0
  171. package/skills/skill-creator/SKILL.md +85 -0
  172. package/skills/systematic-debugging/SKILL.md +353 -0
  173. package/skills/test-driven-development/SKILL.md +335 -0
  174. package/skills/update-agents-md/SKILL.md +282 -0
  175. package/templates/coms-feature-manager-prompt.md +313 -0
@@ -0,0 +1,828 @@
1
+ /**
2
+ * Enforcement handler — blocks forbidden commands (python/pip, git protection,
3
+ * remote script execution, memory writes, dangerous) + memory-based enforcement rules.
4
+ * Worktree-aware: resolves file edits to the correct worktree root for per-worktree
5
+ * review state tracking and gitignore checks.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { loadEnforcedEntries, matchToolCall, matchBashCommand, executeAction } from "./enforcement-rules.js";
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+ import path, { join, resolve, dirname } from "node:path";
14
+ import { supportsAsyncLlm } from "./async-capability.js";
15
+ import { getSetting } from "./project-settings.js";
16
+ import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
17
+ import {
18
+ DANGEROUS,
19
+ getCurrentBranch,
20
+ getMainBranch,
21
+ getProtectedBranches,
22
+ getPrMergeStatus,
23
+ hasGitSub,
24
+ isBranchAhead,
25
+ isBranchMerged,
26
+ isGitRepo,
27
+ runGit,
28
+ } from "./git-helpers.js";
29
+ import { spawnSync } from "node:child_process";
30
+ import { markNeedsReview, isReviewClean, readReviewState, statePath, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
31
+ import {
32
+ checkPythonPipBlock,
33
+ checkRemoteExecBlock,
34
+ checkTempFileEnforcement,
35
+ hasGitAddBulk,
36
+ hasGitAddForce,
37
+ isReadOnlyStatement,
38
+ isRmInProjectTmp,
39
+ normalizeForRepeatCheck,
40
+ resolveEffectiveCwd,
41
+ stripHeredocBodies,
42
+ escapeForDoubleQuote,
43
+ escapeForSingleQuote,
44
+ isTestRunnerCommand,
45
+ } from "./enforcement-helpers.js";
46
+
47
+ type EnforcementResult = { block: true; reason: string } | { autofix: true; modifiedCommand: string; reason: string } | undefined;
48
+
49
+ // Repeat detection via temp file — immune to module reload / closure issues
50
+ // Repeat detection file — set to project dir on first use
51
+ let REPEAT_FILE = "";
52
+
53
+ /** Cached trailer selection for comma-separated commit_trailer values (reset on session_start) */
54
+ let cachedTrailerSelection: string | null = null;
55
+
56
+ function ensureRepeatFile(cwd: string): void {
57
+ if (!REPEAT_FILE) {
58
+ REPEAT_FILE = join(getProjectTmpDir(cwd), `.repeat-${process.pid}.json`);
59
+ }
60
+ }
61
+
62
+ function readRepeatState(): { lastCmd: string; count: number } {
63
+ if (!REPEAT_FILE) return { lastCmd: "", count: 0 };
64
+ try {
65
+ return JSON.parse(readFileSync(REPEAT_FILE, "utf-8"));
66
+ } catch {
67
+ return { lastCmd: "", count: 0 };
68
+ }
69
+ }
70
+
71
+ function writeRepeatState(state: { lastCmd: string; count: number }): void {
72
+ if (!REPEAT_FILE) return;
73
+ try {
74
+ writeFileSync(REPEAT_FILE, JSON.stringify(state));
75
+ } catch (e: any) { console.debug("[enforcement] write repeat state failed:", e?.message || e); }
76
+ }
77
+
78
+
79
+
80
+ /** Inject commit trailer into command (Assisted-by, Co-authored-by, etc.) */
81
+ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd: string): Promise<EnforcementResult> {
82
+ const trailerSetting = getSetting(gitCwd, "commit_trailer");
83
+ if (typeof trailerSetting !== "string") return undefined;
84
+
85
+ const modelId = (ctx as any).model?.id;
86
+ if (!modelId) return undefined;
87
+
88
+ const piIdentity = `PI (${modelId}) <noreply@pi.dev>`;
89
+ let trailerName: string;
90
+
91
+ if (trailerSetting.includes(",")) {
92
+ // Multiple trailer name options — ask user directly
93
+ const options = trailerSetting.split(",").map(s => s.trim()).filter(Boolean);
94
+ if (options.length === 0) {
95
+ return {
96
+ block: true,
97
+ reason: `⛔ Malformed commit_trailer setting: "${trailerSetting.replace(/[\x00-\x1f\x7f-\x9f]/g, "")}" — no valid trailer names found. Fix the setting in .pi/pi-config-settings.json or PI_COMMIT_TRAILER env var.`,
98
+ };
99
+ }
100
+ if (options.length === 1) {
101
+ trailerName = options[0];
102
+ } else if (cachedTrailerSelection && options.includes(cachedTrailerSelection)) {
103
+ trailerName = cachedTrailerSelection;
104
+ } else if (!ctx.hasUI) {
105
+ // No UI available — default to first option
106
+ trailerName = options[0];
107
+ } else {
108
+ const selected = await ctx.ui.select(
109
+ "Select commit trailer:",
110
+ options,
111
+ );
112
+ if (!selected || !options.includes(selected)) {
113
+ return {
114
+ block: true,
115
+ reason: "Commit trailer selection cancelled by user.",
116
+ };
117
+ }
118
+ trailerName = selected;
119
+ cachedTrailerSelection = selected;
120
+ }
121
+ } else {
122
+ // Single trailer name
123
+ trailerName = trailerSetting;
124
+ }
125
+
126
+ // Raw trailer line for duplicate detection (what the commit message should contain)
127
+ const rawTrailerLine = `${trailerName}: ${piIdentity}`;
128
+ if (command.includes(rawTrailerLine)) return undefined;
129
+
130
+ // Pattern A: echo "..." | git commit -F -
131
+ const pipeIdx = command.lastIndexOf("|");
132
+ if (pipeIdx !== -1 && /git\s+commit\s+.*-F\s*-/.test(command.slice(pipeIdx))) {
133
+ const echoPart = command.slice(0, pipeIdx);
134
+ const gitPart = command.slice(pipeIdx);
135
+ const lastDoubleQuote = echoPart.lastIndexOf('"');
136
+ const lastSingleQuote = echoPart.lastIndexOf("'");
137
+ const lastQuoteIdx = Math.max(lastDoubleQuote, lastSingleQuote);
138
+ if (lastQuoteIdx > 0) {
139
+ // Determine quote context for correct escaping
140
+ const quoteChar = echoPart[lastQuoteIdx];
141
+ const escaped = quoteChar === "'"
142
+ ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
143
+ : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
144
+ event.input.command =
145
+ echoPart.slice(0, lastQuoteIdx) + `\\n\\n${escaped.replace(/\n/g, "\\n")}` + echoPart.slice(lastQuoteIdx) + gitPart;
146
+ }
147
+ }
148
+ // Pattern B: git commit -m "..." or git commit -m '...'
149
+ else {
150
+ const mFlagMatch = command.match(/git\s+commit\s+.*-m\s+(["'])([\s\S]*?)\1/);
151
+ if (mFlagMatch) {
152
+ const quoteChar = mFlagMatch[1];
153
+ const escaped = quoteChar === "'"
154
+ ? `${escapeForSingleQuote(trailerName)}: ${piIdentity}`
155
+ : `${escapeForDoubleQuote(trailerName)}: ${piIdentity}`;
156
+ const fullMatch = mFlagMatch[0];
157
+ const insertPos = command.indexOf(fullMatch) + fullMatch.length - 1;
158
+ event.input.command =
159
+ command.slice(0, insertPos) + `\n\n${escaped}` + command.slice(insertPos);
160
+ }
161
+ }
162
+
163
+ return undefined;
164
+ }
165
+
166
+ /** Git protection: worktrees, add ., gitignored files, hooks, protected branches, trailers, DCO */
167
+ async function checkGitProtection(command: string, event: any, ctx: any, gitCwd: string): Promise<EnforcementResult> {
168
+ if (!isGitRepo(gitCwd)) return undefined;
169
+
170
+ // Block branch creation AND switching when use_worktrees is enabled
171
+ if (getSetting(gitCwd, "use_worktrees")) {
172
+ const mb = getMainBranch(gitCwd) || "main";
173
+ const hint = `Use: git worktree add .worktrees/<name> -b <branch> ${mb}`;
174
+ if (hasGitSub(command, "checkout") && !/\bgit\b.*\bcheckout\b.*\s--\s/.test(command)) {
175
+ return { block: true, reason: `⛔ git checkout blocked — use_worktrees is enabled. ${hint}` };
176
+ }
177
+ if (hasGitSub(command, "switch")) {
178
+ return { block: true, reason: `⛔ git switch blocked — use_worktrees is enabled. ${hint}` };
179
+ }
180
+ }
181
+
182
+ // Block git add . / git add -A
183
+ if (
184
+ hasGitSub(command, "add") &&
185
+ hasGitAddBulk(command)
186
+ ) {
187
+ return {
188
+ block: true,
189
+ reason:
190
+ "⛔ 'git add .' / 'git add -A' forbidden. Stage specific files.",
191
+ };
192
+ }
193
+
194
+ // Block git add --force / -f (including combined short options like -fn) — bypasses .gitignore
195
+ if (hasGitAddForce(command)) {
196
+ return {
197
+ block: true,
198
+ reason: "⛔ 'git add -f/--force' forbidden. It bypasses .gitignore and stages ignored files.",
199
+ };
200
+ }
201
+
202
+ // Block staging gitignored files
203
+ if (hasGitSub(command, "add") && !hasGitAddBulk(command)) {
204
+ // Extract file paths from git add command
205
+ const addMatch = command.match(/\bgit\b.*\badd\b\s+(.+)/);
206
+ if (addMatch) {
207
+ const files = addMatch[1].split(/\s+/).filter((f) => !f.startsWith("-"));
208
+ for (const file of files) {
209
+ const checkIgnored = runGit(["check-ignore", "-q", file], gitCwd);
210
+ if (checkIgnored.code === 0) {
211
+ return {
212
+ block: true,
213
+ reason: `⛔ '${file}' is in .gitignore. Do not stage ignored files.`,
214
+ };
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // Block hooks bypass
221
+ if (command.includes("core.hooksPath=/dev/null") || command.includes("core.hooksPath=\"/dev/null\"")) {
222
+ return {
223
+ block: true,
224
+ reason: "⛔ Bypassing git hooks via core.hooksPath=/dev/null is forbidden.",
225
+ };
226
+ }
227
+ if (hasGitSub(command, "commit") && command.includes("--no-verify")) {
228
+ return {
229
+ block: true,
230
+ reason: "⛔ --no-verify forbidden. Pre-commit hooks must run.",
231
+ };
232
+ }
233
+
234
+ const branch = getCurrentBranch(gitCwd);
235
+ const mainBranch = getMainBranch(gitCwd);
236
+ const protectedBranches = getProtectedBranches(gitCwd);
237
+
238
+ // Block commits to protected branches (unless allow_push_to_protected is set)
239
+ if (hasGitSub(command, "commit")) {
240
+ if (!branch)
241
+ return {
242
+ block: true,
243
+ reason:
244
+ "⛔ Detached HEAD. Create a branch first: git checkout -b my-branch",
245
+ };
246
+ if (protectedBranches.has(branch) && !getSetting(gitCwd, "allow_push_to_protected_branches"))
247
+ return {
248
+ block: true,
249
+ reason: `⛔ Cannot commit to '${branch}' (protected). Create a feature branch.\nHint: If you're combining git checkout + git commit in one bash call, split them into SEPARATE bash calls. Branch is checked before execution.`,
250
+ };
251
+
252
+ const pr = getPrMergeStatus(branch, gitCwd);
253
+ if (pr.merged)
254
+ return {
255
+ block: true,
256
+ reason: `⛔ PR #${pr.info} for '${branch}' already merged. Create a new branch from ${mainBranch || "main"}.`,
257
+ };
258
+
259
+ if (command.includes("--amend") && isBranchAhead(gitCwd))
260
+ return undefined;
261
+
262
+ if (mainBranch && isBranchMerged(branch, mainBranch, gitCwd))
263
+ return {
264
+ block: true,
265
+ reason: `⛔ Branch '${branch}' already merged into '${mainBranch}'. Create a new branch.`,
266
+ };
267
+
268
+ // Commit trailer injection
269
+ const trailerResult = await handleCommitTrailer(command, event, ctx, gitCwd);
270
+ if (trailerResult) return trailerResult;
271
+
272
+ // DCO enforcement — inject --signoff when dco setting is enabled
273
+ if (getSetting(gitCwd, "dco") && !/(?:^|\s)--signoff(?:\s|$)/.test(command) && !/(?:^|\s)-s(?:\s|$)/.test(command)) {
274
+ event.input.command = event.input.command.replace(
275
+ /\bgit\b((?:\s+(?:-[a-zA-Z]\s+\S+|-\S+))*\s+)commit\b/,
276
+ "git$1commit --signoff",
277
+ );
278
+ }
279
+ }
280
+
281
+ // Block pushes to protected branches
282
+ if (hasGitSub(command, "push")) {
283
+ // Block if currently on a protected branch
284
+ if (!getSetting(gitCwd, "allow_push_to_protected_branches")) {
285
+ if (branch && protectedBranches.has(branch))
286
+ return {
287
+ block: true,
288
+ reason: `⛔ Cannot push to '${branch}' (protected). Create a feature branch.\nHint: If you're combining git checkout + git push in one bash call, split them into SEPARATE bash calls. Branch is checked before execution.`,
289
+ };
290
+ // Block explicit push to any protected branch (e.g., git push origin v2.10)
291
+ for (const pb of protectedBranches) {
292
+ if (new RegExp(`\\bgit\\b.*\\bpush\\b.*\\b${pb.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(command))
293
+ return {
294
+ block: true,
295
+ reason: `⛔ Cannot push to '${pb}' (protected). Create a feature branch.`,
296
+ };
297
+ }
298
+ }
299
+ if (branch) {
300
+ const pr = getPrMergeStatus(branch, gitCwd);
301
+ if (pr.merged)
302
+ return {
303
+ block: true,
304
+ reason: `⛔ PR #${pr.info} for '${branch}' already merged. Create a new branch.`,
305
+ };
306
+ if (mainBranch && isBranchMerged(branch, mainBranch, gitCwd))
307
+ return {
308
+ block: true,
309
+ reason: `⛔ Branch '${branch}' already merged into '${mainBranch}'. Create a new branch.`,
310
+ };
311
+ }
312
+ }
313
+
314
+ return undefined;
315
+ }
316
+
317
+
318
+
319
+ /** Dangerous command confirmation with UI prompt */
320
+ async function checkDangerousCommands(command: string, cwd: string, ctx: any): Promise<EnforcementResult> {
321
+ // Strip heredoc content — heredoc text is not executable
322
+ const cmdForDangerCheck = stripHeredocBodies(command);
323
+ // Collapse bash line continuations (backslash-newline) before splitting
324
+ const normalized = cmdForDangerCheck.replace(/\\\r?\n/g, " ");
325
+ // Split on statement separators AND pipes to isolate individual commands.
326
+ // NOTE: || must appear before | in the regex so the engine matches || greedily first.
327
+ // NOTE: Pipe split does not respect shell quoting (e.g., echo "a|b" splits incorrectly).
328
+ // This biases toward false positives (extra prompts), which is acceptable for security.
329
+ // Do NOT add quoting awareness here — it would flip the bias toward false negatives.
330
+ const statements = normalized.split(/\n|;|&&|\|\||\|/).map(s => s.trim()).filter(Boolean);
331
+ const hasDangerous = statements.some((stmt) => {
332
+ // Skip read-only commands with no dangerous subshells
333
+ if (isReadOnlyStatement(stmt)) return false;
334
+ return DANGEROUS.some((p) => p.test(stmt));
335
+ });
336
+ if (hasDangerous) {
337
+ // Allow rm -rf targeting only .pi/tmp/ paths without confirmation
338
+ const allRmInTmp = statements
339
+ .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
340
+ .every((stmt) => isRmInProjectTmp(stmt, cwd));
341
+ if (allRmInTmp) return undefined;
342
+
343
+ if (!ctx.hasUI)
344
+ return {
345
+ block: true,
346
+ reason: "Dangerous command blocked (no UI for confirmation). Do NOT retry with an equivalent command (e.g., find -delete, perl, python os.remove). Stop and report this block to the user.",
347
+ };
348
+
349
+ const ok = await ctx.ui.select(
350
+ `⚠️ Dangerous command:\n\n ${command}\n\nAllow?`,
351
+ ["Yes", "No"],
352
+ );
353
+ if (ok !== "Yes") return { block: true, reason: "Blocked by user. Do NOT retry with an equivalent command (e.g., find -delete, perl, python os.remove). Stop and report this block to the user." };
354
+ }
355
+
356
+ return undefined;
357
+ }
358
+
359
+ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): void {
360
+
361
+ pi.on("session_start", (_event, ctx) => {
362
+ cachedTrailerSelection = null;
363
+ // Set comment signature env var for CLI tools to read
364
+ if (getSetting(ctx.cwd, "comment_signature")) {
365
+ const modelId = (ctx as any).model?.id || "unknown";
366
+ process.env.PI_COMMENT_SIGNATURE = `Assisted-by: PI (${modelId})`;
367
+ } else {
368
+ delete process.env.PI_COMMENT_SIGNATURE;
369
+ }
370
+ });
371
+
372
+ pi.on("tool_call", async (event, ctx) => {
373
+ // Memory-based enforcement — block rules checked before execution (all tool types)
374
+ try {
375
+ const entries = loadEnforcedEntries(ctx.cwd);
376
+ const blockEntries = entries.filter(e => e.action === "block");
377
+ if (blockEntries.length > 0) {
378
+ const toolName = isToolCallEventType("bash", event) ? "bash"
379
+ : isToolCallEventType("write", event) ? "write"
380
+ : isToolCallEventType("edit", event) ? "edit"
381
+ : isToolCallEventType("read", event) ? "read"
382
+ : (event as any).toolName || "";
383
+ const input = (event as any).input || {};
384
+ const matches = matchToolCall(blockEntries, toolName, input);
385
+ if (matches.length > 0) {
386
+ const rule = matches[0].rule;
387
+ return { block: true, reason: `⛔ ENFORCEMENT [${rule.entry.class}]: ${rule.text}` };
388
+ }
389
+ }
390
+ } catch { /* enforcement should never break normal flow */ }
391
+
392
+ // Block orchestrator from using edit/write directly — must delegate to subagents.
393
+ // Subagents (PI_SUBAGENT_CHILD=1) are allowed.
394
+ if (process.env.PI_SUBAGENT_CHILD !== "1" && getSetting(ctx.cwd, "orchestrator_edit_write_block")) {
395
+ if (isToolCallEventType("edit", event) || isToolCallEventType("write", event)) {
396
+ const toolUsed = isToolCallEventType("edit", event) ? "edit" : "write";
397
+ return {
398
+ block: true,
399
+ reason: `⛔ Orchestrator cannot use ${toolUsed} directly. Delegate to a subagent (e.g. worker, python-expert, ts-expert) via the subagent tool.`,
400
+ };
401
+ }
402
+ }
403
+
404
+ // Block direct manipulation of review state file — prevents LLM from bypassing review loop
405
+ if (isToolCallEventType("edit", event) || isToolCallEventType("write", event)) {
406
+ const filePath: string | undefined = (event as any).input?.path;
407
+ if (filePath && resolve(ctx.cwd, filePath) === statePath(ctx.cwd)) {
408
+ return {
409
+ block: true,
410
+ reason: "\u26d4 Direct modification of pi-config-review-state.json blocked. The review state is managed by the enforcement system.",
411
+ };
412
+ }
413
+ }
414
+
415
+ if (!isToolCallEventType("bash", event)) return undefined;
416
+ const command = event.input.command;
417
+ const cmdLower = command.trim().toLowerCase();
418
+
419
+ // Block ALL bash commands targeting pi-config-review-state — no exceptions.
420
+ // The review state is managed exclusively by the enforcement system.
421
+ // If you need to inspect it, do it outside of pi.
422
+ if (getSetting(ctx.cwd, "review_loop_enforcement") && cmdLower.includes("pi-config-review-state")) {
423
+ return {
424
+ block: true,
425
+ reason: "\u26d4 Bash command targeting pi-config-review-state blocked. The review state is managed by the enforcement system.",
426
+ };
427
+ }
428
+
429
+ // Block repeated identical commands (polling-by-spam) — orchestrator only.
430
+ // Skip on acpx: async LLM agents are unavailable; foreground polls are expected.
431
+ const parentSupportsAsyncLlm = supportsAsyncLlm(
432
+ (ctx as any).model?.provider,
433
+ ctx.cwd,
434
+ );
435
+ if (parentSupportsAsyncLlm && process.env.PI_SUBAGENT_CHILD !== "1") {
436
+ ensureRepeatFile(ctx.cwd);
437
+ const normalized = normalizeForRepeatCheck(command);
438
+ const rs = readRepeatState();
439
+ if (normalized === rs.lastCmd) {
440
+ rs.count++;
441
+ writeRepeatState(rs);
442
+ if (rs.count >= 3) {
443
+ return {
444
+ block: true,
445
+ reason: `⛔ Same command executed ${rs.count} times in a row. Use a subagent with async: true for polling/monitoring instead of repeating the command.`,
446
+ };
447
+ }
448
+ } else {
449
+ writeRepeatState({ lastCmd: normalized, count: 1 });
450
+ }
451
+ }
452
+
453
+ // Strip timeout on long-running poll commands — these can take 30+ minutes
454
+ // (rate limit waits). The LLM keeps setting timeouts despite prompt instructions.
455
+ // Instead of blocking (which causes infinite retry loops), silently remove the timeout.
456
+ if (/\bmyk-pi-tools\b[\s\S]*\breviews\s+poll\b/.test(command) && event.input.timeout) {
457
+ delete event.input.timeout;
458
+ }
459
+
460
+ const pythonCheck = checkPythonPipBlock(command, cmdLower);
461
+ if (pythonCheck) {
462
+ if ("autofix" in pythonCheck) {
463
+ event.input.command = pythonCheck.modifiedCommand;
464
+ // Don't block — continue with the modified command
465
+ } else {
466
+ return pythonCheck;
467
+ }
468
+ }
469
+
470
+ // Block memory writes from specialist agents — only orchestrator can write
471
+ if (process.env.PI_SUBAGENT_CHILD === "1" && /\bmyk-pi-tools\b.*\bmemory\s+(add|delete)\b/.test(command)) {
472
+ return {
473
+ block: true,
474
+ reason: "Memory writes are restricted to the orchestrator. Specialists can only search/list memories.",
475
+ };
476
+ }
477
+
478
+ // Block direct docker/podman CLI in container — force docker-safe wrapper
479
+ if (inContainer && /(?:^|[|;&]\s*)(?:docker|podman)\s/.test(cmdLower) && !cmdLower.includes("docker-safe")) {
480
+ return {
481
+ block: true,
482
+ reason: "Direct docker/podman forbidden. Use docker-safe for read-only container inspection (ps, logs, inspect, top, stats).",
483
+ };
484
+ }
485
+
486
+ // Block sleep inside loops / long sleeps — force async on native providers only.
487
+ // On acpx, do not push async (unsupported); foreground waits are allowed.
488
+ const hasLoop = /\b(while|for|until)\b/.test(command);
489
+ const sleepMatch = command.match(/\bsleep\s+(\d+)/);
490
+ if (parentSupportsAsyncLlm && hasLoop && sleepMatch && parseInt(sleepMatch[1], 10) > 5) {
491
+ return {
492
+ block: true,
493
+ reason: `⚠️ Polling loop with sleep ${sleepMatch[1]}s blocked. Use subagent with async: true for polling/monitoring tasks instead of blocking the session.`,
494
+ };
495
+ }
496
+
497
+ if (parentSupportsAsyncLlm && !hasLoop && sleepMatch && parseInt(sleepMatch[1], 10) > 30) {
498
+ return {
499
+ block: true,
500
+ reason: `⚠️ sleep ${sleepMatch[1]}s blocked — too long. Use subagent with async: true instead of blocking the session.`,
501
+ };
502
+ }
503
+
504
+ const remoteCheck = checkRemoteExecBlock(cmdLower);
505
+ if (remoteCheck) return remoteCheck;
506
+
507
+ // Enforce git commit/push only via git-expert agent
508
+ if (process.env.PI_AGENT_NAME !== "git-expert") {
509
+ if (hasGitSub(command, "commit") || hasGitSub(command, "push")) {
510
+ return {
511
+ block: true,
512
+ reason: "⛔ git commit/push blocked. Use git-expert agent for commit and push operations.",
513
+ };
514
+ }
515
+ }
516
+
517
+ // Enforce review loop — block commit unless review state is clean
518
+ // Use resolveEffectiveCwd to detect the worktree from cd commands (e.g., cd .worktrees/issue-622 && git commit)
519
+ if (process.env.PI_AGENT_NAME === "git-expert" && hasGitSub(command, "commit")) {
520
+ const commitCwd = resolveEffectiveCwd(command, ctx.cwd);
521
+ if (getSetting(ctx.cwd, "review_loop_enforcement") && !isReviewClean(commitCwd)) {
522
+ const state = readReviewState(commitCwd);
523
+ const testInfo = state.status === "clean" && !state.tests_passed ? " Tests have not passed yet — run tests before committing." : "";
524
+ const reviewAdvice = state.status !== "clean" ? " Fix findings and re-run all reviewers until 0 comments." : "";
525
+ return {
526
+ block: true,
527
+ reason: `\u26d4 Review loop incomplete (status: ${state.status}, cycle ${state.cycle}, ${state.findings_count} findings, ${state.reviewers_pending.length} reviewers pending, tests_passed: ${state.tests_passed}).${testInfo}${reviewAdvice}`,
528
+ };
529
+ }
530
+ }
531
+
532
+ // Git protection
533
+ const gitCwd = resolveEffectiveCwd(command, ctx.cwd);
534
+ const gitCheck = await checkGitProtection(command, event, ctx, gitCwd);
535
+ if (gitCheck) return gitCheck;
536
+
537
+ const tmpCheck = checkTempFileEnforcement(command, ctx.cwd);
538
+ if (tmpCheck) return tmpCheck;
539
+
540
+ const dangerCheck = await checkDangerousCommands(command, ctx.cwd, ctx);
541
+ if (dangerCheck) return dangerCheck;
542
+
543
+ return undefined;
544
+ });
545
+
546
+ // ── Memory-based enforcement (tool_result hook) ───────────────────
547
+ // After a tool completes, check if any enforced memory entry's trigger
548
+ // matches. Executes block/run_after/warn actions.
549
+ pi.on("tool_result", async (event, ctx) => {
550
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
551
+
552
+ const toolName = (event as any).toolName as string;
553
+ const input = (event as any).input || {};
554
+
555
+ let entries: ReturnType<typeof loadEnforcedEntries>;
556
+ try {
557
+ entries = loadEnforcedEntries(ctx.cwd);
558
+ } catch (e: any) {
559
+ console.debug("[enforcement] loadEnforcedEntries failed:", e?.message?.slice(0, 100));
560
+ return;
561
+ }
562
+ if (entries.length === 0) return;
563
+
564
+
565
+
566
+ let matches = matchToolCall(entries, toolName, input);
567
+
568
+ // For subagent results, extract actual bash commands from the subagent's
569
+ // tool call messages (structured data only — no prose text fallback).
570
+ if (toolName === "subagent") {
571
+ const details = (event as any).details;
572
+ const results = Array.isArray(details?.results) ? details.results : [];
573
+ if (!Array.isArray(details?.results) && details?.results) {
574
+ console.debug(`[enforcement] subagent details.results is ${typeof details.results} (expected array), skipping bash extraction`);
575
+ }
576
+ const nonBlockEntries = entries.filter(e => e.action !== "block");
577
+ const seen = new Set<string>();
578
+ const bashCommands: string[] = [];
579
+ for (const r of results) {
580
+ const msgs = Array.isArray(r?.messages) ? r.messages : [];
581
+ for (const msg of msgs) {
582
+ if (msg?.role !== "assistant") continue;
583
+ const parts = Array.isArray(msg?.content) ? msg.content : [];
584
+ for (const part of parts) {
585
+ if (part?.type !== "toolCall") continue;
586
+ // Check both field naming conventions (name/arguments and toolName/args)
587
+ const partToolName = part?.name || part?.toolName;
588
+ const partToolArgs = part?.arguments || part?.args;
589
+ if (partToolName === "bash" && partToolArgs?.command) {
590
+ bashCommands.push(partToolArgs.command);
591
+ }
592
+ // Also check non-bash tool calls against tool_name/file_modified triggers
593
+ if (partToolName && partToolName !== "bash") {
594
+ const toolMatches = matchToolCall(
595
+ nonBlockEntries,
596
+ partToolName,
597
+ partToolArgs || {},
598
+ );
599
+ for (const m of toolMatches) {
600
+ if (!seen.has(m.rule.hash)) {
601
+ seen.add(m.rule.hash);
602
+ matches.push(m);
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+ }
609
+ if (bashCommands.length > 0) {
610
+ // Match against ALL structured bash commands
611
+ for (const cmd of bashCommands) {
612
+ const cmdMatches = matchBashCommand(nonBlockEntries, cmd);
613
+ for (const m of cmdMatches) {
614
+ if (!seen.has(m.rule.hash)) {
615
+ seen.add(m.rule.hash);
616
+ matches.push(m);
617
+ }
618
+ }
619
+ }
620
+ }
621
+ // No fallback to prose text — only structured tool calls are reliable.
622
+ // Prose matching causes false positives (e.g., "git status" in a sentence).
623
+ }
624
+
625
+ if (matches.length === 0) return;
626
+
627
+ const currentContent = (event as any).content || [];
628
+ const appendMessages: Array<{ type: string; text: string }> = [];
629
+
630
+ for (const { rule } of matches) {
631
+ if (rule.action === "block") {
632
+ // Block is handled in tool_call hook (before execution)
633
+ // tool_result only handles run_after and warn
634
+ continue;
635
+ }
636
+
637
+ if (rule.action === "run_after" && rule.actionCommand && !(event as any).isError) {
638
+ const result = executeAction(rule.actionCommand, ctx.cwd);
639
+ if (result.success) {
640
+ appendMessages.push({
641
+ type: "text",
642
+ text: `\n✅ Auto-enforced: ${rule.actionCommand}\n${result.output}`,
643
+ });
644
+ } else {
645
+ appendMessages.push({
646
+ type: "text",
647
+ text: `\n❌ Enforcement failed: ${rule.actionCommand}\n${result.output}`,
648
+ });
649
+ }
650
+ }
651
+
652
+ if (rule.action === "warn") {
653
+ appendMessages.push({
654
+ type: "text",
655
+ text: `\n⚠️ ENFORCEMENT WARNING: ${rule.text}`,
656
+ });
657
+ }
658
+ }
659
+
660
+ if (appendMessages.length > 0) {
661
+ return {
662
+ content: [...currentContent, ...appendMessages],
663
+ };
664
+ }
665
+ });
666
+
667
+ // ── Review state tracking (edit/write detection) ────────────────────
668
+ // Runs in ALL processes (orchestrator + subagents) to catch edits from any specialist.
669
+ pi.on("tool_result", async (event, ctx) => {
670
+ if (!getSetting(ctx.cwd, "review_loop_enforcement")) return;
671
+
672
+ const toolName = (event as any).toolName as string;
673
+ if (toolName !== "edit" && toolName !== "write") return;
674
+
675
+ const input = (event as any).input || {};
676
+ const filePath: string | undefined = input.path;
677
+ if (!filePath) return;
678
+
679
+ // Skip failed edits — only successful changes need review
680
+ const isError = (event as any).isError === true;
681
+ if (isError) return;
682
+
683
+ // Resolve the worktree root from the file's location.
684
+ // This handles files in any worktree (not just .worktrees/) by asking git
685
+ // which worktree the file belongs to via --show-toplevel.
686
+ const absPath = resolve(ctx.cwd, filePath);
687
+ const fileDir = dirname(absPath);
688
+ let effectiveCwd: string;
689
+ try {
690
+ effectiveCwd = resolveWorktreeRoot(fileDir);
691
+ } catch {
692
+ effectiveCwd = ctx.cwd; // fallback to session cwd
693
+ }
694
+ const relativePath = path.relative(effectiveCwd, absPath);
695
+
696
+ // Skip gitignored files (build artifacts, .pi/tmp/, node_modules, etc.)
697
+ // Run from the worktree root so files in worktrees are checked correctly
698
+ // (from the main repo, .worktrees/ itself is gitignored — false positive).
699
+ try {
700
+ const result = spawnSync("git", ["check-ignore", "-q", relativePath], {
701
+ cwd: effectiveCwd,
702
+ timeout: 3000,
703
+ stdio: "ignore",
704
+ });
705
+ if (result.status === 0) return; // gitignored — skip
706
+ } catch {
707
+ // git not available or error — proceed with marking (safe default)
708
+ }
709
+
710
+ // Retry markNeedsReview on lock failure — missing this would leave state
711
+ // as 'clean' and allow commits to slip through enforcement.
712
+ // Uses effectiveCwd so each worktree gets its own pi-config-review-state.json.
713
+ for (let attempt = 0; attempt < 3; attempt++) {
714
+ try { markNeedsReview(effectiveCwd); break; } catch {
715
+ if (attempt === 2) {
716
+ // Last resort: log but don't crash the extension
717
+ console.error("[enforcement] markNeedsReview failed after 3 attempts — review state may be stale");
718
+ }
719
+ }
720
+ }
721
+ });
722
+
723
+ // ── Test command detection (auto-mark tests_passed in review state) ──
724
+ // Runs in ALL processes (orchestrator + subagents) to catch test runs from any agent.
725
+ // When a test command exits successfully, marks tests_passed=true in pi-config-review-state.json.
726
+ // When it fails, marks tests_passed=false. Any subsequent file edit resets tests_passed
727
+ // via markNeedsReview(), so stale results are impossible.
728
+ // NOTE: Code reviewers are excluded — they may run tests to verify behavior but their
729
+ // test runs should not mark the project's test state. Only intentional test runs count.
730
+ pi.on("tool_result", async (event, ctx) => {
731
+ const toolName = (event as any).toolName as string;
732
+ if (toolName !== "bash") return;
733
+
734
+ // Skip test detection for code reviewers — their test runs are verification, not validation
735
+ const agentName = process.env.PI_AGENT_NAME || "";
736
+ if (agentName.startsWith("code-reviewer-")) return;
737
+
738
+ const command: string = (event as any).input?.command || "";
739
+ if (!command) return;
740
+
741
+ const isTestCommand = isTestRunnerCommand(command);
742
+ if (!isTestCommand) return;
743
+
744
+ // Resolve the effective cwd BEFORE checking settings — the command may target
745
+ // a different repo (cd <dir> or git -C <dir>), so we need that repo's settings.
746
+ const effectiveCwd = resolveEffectiveCwd(command, ctx.cwd);
747
+ if (!getSetting(effectiveCwd, "review_loop_enforcement")) return;
748
+
749
+ const isError = (event as any).isError === true;
750
+
751
+ for (let attempt = 0; attempt < 3; attempt++) {
752
+ try {
753
+ if (isError) {
754
+ markTestsFailed(effectiveCwd);
755
+ } else {
756
+ markTestsPassed(effectiveCwd);
757
+ }
758
+ break;
759
+ } catch {
760
+ if (attempt === 2) {
761
+ console.error("[enforcement] markTests(Passed|Failed) failed after 3 attempts");
762
+ }
763
+ }
764
+ }
765
+ });
766
+
767
+ // ── /review-status command — read-only access to review state ──────
768
+ // Usage: /review-status [worktree-path]
769
+ // Without args: shows main repo state. With path: shows that worktree's state.
770
+ // Autocomplete for /review-status is provided by extended-autocomplete.ts
771
+ // via the registerCommand wrapper (lists active worktrees).
772
+ pi.registerCommand("review-status", {
773
+ description: "Show current review loop enforcement state. Pass a worktree path to check its state.",
774
+ handler: async (args, ctx) => {
775
+ if (!ctx.hasUI) return;
776
+ const worktreePath = args?.trim() || "";
777
+ const targetCwd = worktreePath ? resolve(ctx.cwd, worktreePath) : ctx.cwd;
778
+ const state = readReviewState(targetCwd);
779
+ const enabled = getSetting(ctx.cwd, "review_loop_enforcement");
780
+ const worktreeLabel = worktreePath ? ` (worktree: ${worktreePath})` : "";
781
+ const lines = [
782
+ `Review Loop Enforcement: ${enabled ? "enabled" : "disabled"}${worktreeLabel}`,
783
+ `Status: ${state.status}`,
784
+ `Cycle: ${state.cycle}`,
785
+ `Findings: ${state.findings_count}`,
786
+ `Reviewers pending: ${state.reviewers_pending.length}/${state.reviewers_total}${state.reviewers_pending.length > 0 ? " (" + state.reviewers_pending.join(", ") + ")" : ""}`,
787
+ `Edited during cycle: ${state.edited_during_cycle}`,
788
+ `Tests passed: ${state.tests_passed}`,
789
+ `Last edit: ${state.last_edit_at || "none"}`,
790
+ `Last clean: ${state.last_clean_at || "none"}`,
791
+ ];
792
+ ctx.ui.notify(lines.join("\n"), "info");
793
+ },
794
+ });
795
+
796
+ // ── review_status tool — LLM-callable read-only access to review state ──
797
+ pi.registerTool({
798
+ name: "review_status",
799
+ label: "Review Status",
800
+ description: "Get current review loop enforcement state. Use to check if review is needed, in progress, clean, or has findings before committing. Pass worktree_path to check a specific worktree's state.",
801
+ parameters: Type.Object({
802
+ worktree_path: Type.Optional(Type.String({ description: "Path to a worktree directory to check its review state instead of the main repo" })),
803
+ }),
804
+ async execute(_callId, params, _signal, _onUpdate, ctx) {
805
+ const targetCwd = params.worktree_path ? resolve(ctx.cwd, params.worktree_path) : ctx.cwd;
806
+ const state = readReviewState(targetCwd);
807
+ const enabled = getSetting(ctx.cwd, "review_loop_enforcement");
808
+ const prefix = params.worktree_path ? `worktree: ${params.worktree_path}\n` : "";
809
+ const summary = [
810
+ `${prefix}enforcement: ${enabled ? "enabled" : "disabled"}`,
811
+ `status: ${state.status}`,
812
+ `cycle: ${state.cycle}`,
813
+ `findings: ${state.findings_count}`,
814
+ `pending: ${state.reviewers_pending.length}/${state.reviewers_total}${state.reviewers_pending.length > 0 ? " (" + state.reviewers_pending.join(", ") + ")" : ""}`,
815
+ `edited_during_cycle: ${state.edited_during_cycle}`,
816
+ `tests_passed: ${state.tests_passed}`,
817
+ `last_edit: ${state.last_edit_at || "none"}`,
818
+ `last_clean: ${state.last_clean_at || "none"}`,
819
+ ].join("\n");
820
+ return {
821
+ content: [{ type: "text" as const, text: summary }],
822
+ details: { state, enabled, worktree_path: params.worktree_path || null },
823
+ };
824
+ },
825
+ });
826
+ }
827
+
828
+ // trigger re-scan