pi-harness-runtime 1.1.407 → 1.1.408

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 (166) hide show
  1. package/index.ts +78 -0
  2. package/package.json +1 -1
  3. package/packages/a2a-adapter/package.json +1 -1
  4. package/packages/agent-policy/package.json +1 -1
  5. package/packages/architecture-generator/package.json +1 -1
  6. package/packages/auth/package.json +1 -1
  7. package/packages/autonomous-refactor/package.json +1 -1
  8. package/packages/autonomous-runtime/package.json +1 -1
  9. package/packages/cache-strategy/package.json +1 -1
  10. package/packages/capability-registry/package.json +1 -1
  11. package/packages/checkpoint/package.json +1 -1
  12. package/packages/cli-plugin-sdk/package.json +1 -1
  13. package/packages/clipboard/package.json +1 -1
  14. package/packages/clipboard-plugin/package.json +1 -1
  15. package/packages/code-generation/package.json +1 -1
  16. package/packages/code-review/package.json +1 -1
  17. package/packages/codex-adapter/package.json +1 -1
  18. package/packages/codex-watcher/package.json +1 -1
  19. package/packages/config-capture/package.json +1 -1
  20. package/packages/connection-runtime/package.json +1 -1
  21. package/packages/context-compiler/package.json +1 -1
  22. package/packages/context-discovery/package.json +1 -1
  23. package/packages/context-manager/package.json +1 -1
  24. package/packages/contracts/package.json +1 -1
  25. package/packages/cookie-sanitizer/package.json +1 -1
  26. package/packages/cost-optimizer/package.json +1 -1
  27. package/packages/dependency-analyzer/package.json +1 -1
  28. package/packages/django-plugin/package.json +1 -1
  29. package/packages/doc-generator/package.json +1 -1
  30. package/packages/durable-review/package.json +1 -1
  31. package/packages/e2e-tester/package.json +1 -1
  32. package/packages/evaluation-engine/package.json +1 -1
  33. package/packages/evaluation-runner/package.json +1 -1
  34. package/packages/event-bus/package.json +1 -1
  35. package/packages/event-sink/package.json +1 -1
  36. package/packages/event-store/package.json +1 -1
  37. package/packages/experience-replay/package.json +1 -1
  38. package/packages/feedback-collector/package.json +1 -1
  39. package/packages/file-copy-helper/package.json +1 -1
  40. package/packages/framework-detector/package.json +1 -1
  41. package/packages/framework-plugin-sdk/package.json +1 -1
  42. package/packages/frappe-plugin/package.json +1 -1
  43. package/packages/generic-web-plugin/package.json +1 -1
  44. package/packages/health-monitor/package.json +1 -1
  45. package/packages/intent-analyzer/package.json +1 -1
  46. package/packages/jev-judge/package.json +1 -1
  47. package/packages/knowledge-graph/package.json +1 -1
  48. package/packages/knowledge-retrieval/package.json +1 -1
  49. package/packages/laravel-plugin/package.json +1 -1
  50. package/packages/learning-engine/package.json +1 -1
  51. package/packages/logging/package.json +1 -1
  52. package/packages/mcp-adapter/package.json +1 -1
  53. package/packages/memory-engine/package.json +1 -1
  54. package/packages/milestone-manager/package.json +1 -1
  55. package/packages/model-registry/package.json +1 -1
  56. package/packages/nextjs-plugin/package.json +1 -1
  57. package/packages/notification/package.json +1 -1
  58. package/packages/observability/package.json +1 -1
  59. package/packages/okf-indexer/package.json +1 -1
  60. package/packages/paperclip-integration/package.json +1 -1
  61. package/packages/performance-optimizer/package.json +1 -1
  62. package/packages/permission-mode/package.json +1 -1
  63. package/packages/privilege-broker/package.json +1 -1
  64. package/packages/project-analyzer/package.json +1 -1
  65. package/packages/project-bootstrap/package.json +1 -1
  66. package/packages/projection-engine/package.json +1 -1
  67. package/packages/prompt-compiler/package.json +1 -1
  68. package/packages/prompt-versioning/package.json +1 -1
  69. package/packages/provider-adapter-sdk/package.json +1 -1
  70. package/packages/provider-router/package.json +1 -1
  71. package/packages/provider-selector/package.json +1 -1
  72. package/packages/providers/package.json +1 -1
  73. package/packages/qdrant-skills/package.json +1 -1
  74. package/packages/quota-manager/package.json +1 -1
  75. package/packages/rate-limiter/package.json +1 -1
  76. package/packages/react-vite-plugin/package.json +1 -1
  77. package/packages/relay/package.json +1 -1
  78. package/packages/release-manager/package.json +1 -1
  79. package/packages/requirement-compiler/package.json +1 -1
  80. package/packages/runtime/package.json +1 -1
  81. package/packages/scheduler/package.json +1 -1
  82. package/packages/scheduler-adapter/package.json +1 -1
  83. package/packages/session/package.json +1 -1
  84. package/packages/session-api/package.json +1 -1
  85. package/packages/session-export/package.json +1 -1
  86. package/packages/shared-context/package.json +1 -1
  87. package/packages/skill-mcp-client/package.json +1 -1
  88. package/packages/skill-registry/package.json +1 -1
  89. package/packages/skills/package.json +1 -1
  90. package/packages/sprint-planner/package.json +1 -1
  91. package/packages/subscription-engine/package.json +1 -1
  92. package/packages/task-compiler/package.json +1 -1
  93. package/packages/telegram/package.json +1 -1
  94. package/packages/tencentdb-memory/package.json +1 -1
  95. package/packages/tencentdb-sync/package.json +1 -1
  96. package/packages/test-data-generator/package.json +1 -1
  97. package/packages/test-generator/package.json +1 -1
  98. package/packages/todo-bd-sync/package.json +1 -1
  99. package/packages/token-estimation/package.json +1 -1
  100. package/packages/token-optimizer/package.json +1 -1
  101. package/packages/tooling-preference/package.json +1 -1
  102. package/packages/trajectory/package.json +1 -1
  103. package/packages/tui/package.json +1 -1
  104. package/packages/two-phase-review/dist/classify.d.ts +56 -0
  105. package/packages/two-phase-review/dist/classify.d.ts.map +1 -0
  106. package/packages/two-phase-review/dist/classify.js +108 -0
  107. package/packages/two-phase-review/dist/classify.js.map +1 -0
  108. package/packages/two-phase-review/dist/index.d.ts +20 -0
  109. package/packages/two-phase-review/dist/index.d.ts.map +1 -0
  110. package/packages/two-phase-review/dist/index.js +14 -0
  111. package/packages/two-phase-review/dist/index.js.map +1 -0
  112. package/packages/two-phase-review/dist/rules.d.ts +81 -0
  113. package/packages/two-phase-review/dist/rules.d.ts.map +1 -0
  114. package/packages/two-phase-review/dist/rules.js +195 -0
  115. package/packages/two-phase-review/dist/rules.js.map +1 -0
  116. package/packages/two-phase-review/dist/runtime.d.ts +70 -0
  117. package/packages/two-phase-review/dist/runtime.d.ts.map +1 -0
  118. package/packages/two-phase-review/dist/runtime.js +298 -0
  119. package/packages/two-phase-review/dist/runtime.js.map +1 -0
  120. package/packages/two-phase-review/dist/scope.d.ts +68 -0
  121. package/packages/two-phase-review/dist/scope.d.ts.map +1 -0
  122. package/packages/two-phase-review/dist/scope.js +205 -0
  123. package/packages/two-phase-review/dist/scope.js.map +1 -0
  124. package/packages/two-phase-review/dist/signals.d.ts +74 -0
  125. package/packages/two-phase-review/dist/signals.d.ts.map +1 -0
  126. package/packages/two-phase-review/dist/signals.js +297 -0
  127. package/packages/two-phase-review/dist/signals.js.map +1 -0
  128. package/packages/two-phase-review/dist/state.d.ts +72 -0
  129. package/packages/two-phase-review/dist/state.d.ts.map +1 -0
  130. package/packages/two-phase-review/dist/state.js +238 -0
  131. package/packages/two-phase-review/dist/state.js.map +1 -0
  132. package/packages/two-phase-review/dist/types.d.ts +62 -0
  133. package/packages/two-phase-review/dist/types.d.ts.map +1 -0
  134. package/packages/two-phase-review/dist/types.js +9 -0
  135. package/packages/two-phase-review/dist/types.js.map +1 -0
  136. package/packages/two-phase-review/package.json +21 -0
  137. package/packages/two-phase-review/src/classify.ts +129 -0
  138. package/packages/two-phase-review/src/index.ts +110 -0
  139. package/packages/two-phase-review/src/rules.ts +236 -0
  140. package/packages/two-phase-review/src/runtime.ts +392 -0
  141. package/packages/two-phase-review/src/scope.ts +247 -0
  142. package/packages/two-phase-review/src/signals.ts +340 -0
  143. package/packages/two-phase-review/src/state.ts +280 -0
  144. package/packages/two-phase-review/src/types.ts +72 -0
  145. package/packages/two-phase-review/test/classify.test.ts +102 -0
  146. package/packages/two-phase-review/test/helpers.ts +72 -0
  147. package/packages/two-phase-review/test/rules.test.ts +150 -0
  148. package/packages/two-phase-review/test/runtime.test.ts +362 -0
  149. package/packages/two-phase-review/test/scope.test.ts +186 -0
  150. package/packages/two-phase-review/test/signals.test.ts +183 -0
  151. package/packages/two-phase-review/test/state.test.ts +201 -0
  152. package/packages/types/package.json +1 -1
  153. package/packages/voice-api/package.json +1 -1
  154. package/packages/webmcp-tester/package.json +1 -1
  155. package/packages/wiki/package.json +1 -1
  156. package/packages/workflow-events/package.json +1 -1
  157. package/packages/workspace-scanner/package.json +1 -1
  158. package/packages/worktree/dist/worktree.d.ts +31 -0
  159. package/packages/worktree/dist/worktree.d.ts.map +1 -1
  160. package/packages/worktree/dist/worktree.js +21 -8
  161. package/packages/worktree/dist/worktree.js.map +1 -1
  162. package/packages/worktree/package.json +1 -1
  163. package/packages/worktree/src/worktree.ts +49 -8
  164. package/packages/worktree/test/worktree.test.ts +27 -27
  165. package/packages/write-review/package.json +1 -1
  166. package/packages/worktree/worktree.ts +0 -293
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The review scope.
3
+ *
4
+ * The scope of the review is the diff of the task: the changes since the
5
+ * revision the task started at, including the uncommitted ones. It is never
6
+ * the whole repository. A large scope is split into logical chunks so the
7
+ * review can run per module.
8
+ */
9
+
10
+ import { spawnSync } from "node:child_process";
11
+ import type { ReviewChunk } from "./types.js";
12
+
13
+ /** Directories that never belong to a review scope. */
14
+ export const EXCLUDED_SCOPE_PREFIXES: string[] = [
15
+ "node_modules",
16
+ "dist",
17
+ "build",
18
+ "out",
19
+ "coverage",
20
+ ".next",
21
+ ".turbo",
22
+ ".cache",
23
+ "vendor",
24
+ "__pycache__",
25
+ ];
26
+
27
+ /** Is the path part of a review scope? */
28
+ export function isReviewablePath(file: string): boolean {
29
+ const path = file.trim().replace(/^\.\//, "");
30
+ if (!path) return false;
31
+ // An excluded directory is excluded everywhere in the path, not only at
32
+ // its root: "packages/foo/dist/index.js" is generated as well.
33
+ const segments = path.split("/").filter(Boolean);
34
+ return !segments.some((segment) => EXCLUDED_SCOPE_PREFIXES.includes(segment));
35
+ }
36
+
37
+ interface GitResult {
38
+ ok: boolean;
39
+ stdout: string;
40
+ }
41
+
42
+ function runGit(root: string, args: string[]): GitResult {
43
+ try {
44
+ const result = spawnSync("git", ["-C", root, ...args], {
45
+ encoding: "utf8",
46
+ timeout: 20_000,
47
+ shell: false,
48
+ maxBuffer: 16 * 1024 * 1024,
49
+ });
50
+ if (result.error || result.status !== 0) return { ok: false, stdout: "" };
51
+ return { ok: true, stdout: result.stdout ?? "" };
52
+ } catch {
53
+ return { ok: false, stdout: "" };
54
+ }
55
+ }
56
+
57
+ /** Is the workspace a Git repository? */
58
+ export function isGitRepository(root: string): boolean {
59
+ const result = runGit(root, ["rev-parse", "--is-inside-work-tree"]);
60
+ return result.ok && result.stdout.trim() === "true";
61
+ }
62
+
63
+ /** The current revision of the workspace. */
64
+ export function currentRevision(root: string): string | null {
65
+ const result = runGit(root, ["rev-parse", "HEAD"]);
66
+ const revision = result.stdout.trim();
67
+ return result.ok && revision ? revision : null;
68
+ }
69
+
70
+ /** Does the revision exist in the repository? */
71
+ export function revisionExists(root: string, revision: string): boolean {
72
+ return runGit(root, ["cat-file", "-e", `${revision}^{commit}`]).ok;
73
+ }
74
+
75
+ /** The measured size of a scope. */
76
+ export interface ScopeMetrics {
77
+ files: number;
78
+ modules: number;
79
+ changedLines: number;
80
+ }
81
+
82
+ /** The scope of the review. */
83
+ export interface ScopeResult {
84
+ files: string[];
85
+ detected: boolean;
86
+ reason: string;
87
+ metrics: ScopeMetrics;
88
+ }
89
+
90
+ /** The module a file belongs to (the review chunk it is grouped in). */
91
+ export function moduleKey(file: string): string {
92
+ const parts = file.split("/").filter(Boolean);
93
+ if (parts.length >= 2) {
94
+ const root = parts[0];
95
+ if (root === "packages" || root === "apps" || root === "services") {
96
+ return `${root}/${parts[1]}`;
97
+ }
98
+ if (root === "harness") {
99
+ return parts.length >= 3 ? `harness/${parts[1]}` : "harness";
100
+ }
101
+ return root;
102
+ }
103
+ return "root";
104
+ }
105
+
106
+ /** Count the modules a scope crosses. */
107
+ export function countModules(files: string[]): number {
108
+ return new Set(files.map((file) => moduleKey(file))).size;
109
+ }
110
+
111
+ /** Parse `git diff --numstat` into the changed line count. */
112
+ export function parseNumstat(output: string): number {
113
+ let lines = 0;
114
+ for (const row of output.split("\n")) {
115
+ const parts = row.split("\t");
116
+ if (parts.length < 3) continue;
117
+ const added = Number.parseInt(parts[0] ?? "", 10);
118
+ const removed = Number.parseInt(parts[1] ?? "", 10);
119
+ if (Number.isFinite(added)) lines += added;
120
+ if (Number.isFinite(removed)) lines += removed;
121
+ }
122
+ return lines;
123
+ }
124
+
125
+ /**
126
+ * Collect the files the review has to cover.
127
+ *
128
+ * The scope is the diff since the start revision (committed and uncommitted)
129
+ * plus the untracked files. A workspace that is not a Git repository has no
130
+ * honest scope, so nothing is reviewed.
131
+ */
132
+ export function collectScope(root: string, startRevision: string | null): ScopeResult {
133
+ const empty: ScopeResult = {
134
+ files: [],
135
+ detected: false,
136
+ reason: "the workspace is not a Git repository",
137
+ metrics: { files: 0, modules: 0, changedLines: 0 },
138
+ };
139
+
140
+ if (!isGitRepository(root)) return empty;
141
+
142
+ let base = startRevision;
143
+ if (base && !revisionExists(root, base)) {
144
+ // A missing baseline would silently reduce the scope to the last
145
+ // commit, so the scope is empty instead of incomplete.
146
+ return {
147
+ ...empty,
148
+ reason: `the revision the task started at (${base.slice(0, 7)}) does not exist`,
149
+ };
150
+ }
151
+ const revision = base ?? "HEAD";
152
+
153
+ const diff = runGit(root, ["diff", "--name-only", revision]);
154
+ if (!diff.ok) {
155
+ return { ...empty, reason: `the diff against ${revision} could not be read` };
156
+ }
157
+
158
+ const files = new Set<string>();
159
+ for (const line of diff.stdout.split("\n")) {
160
+ const file = line.trim();
161
+ if (file) files.add(file);
162
+ }
163
+
164
+ const untracked = runGit(root, ["ls-files", "--others", "--exclude-standard"]);
165
+ if (!untracked.ok) {
166
+ // Without the untracked files the scope would miss new work.
167
+ return { ...empty, reason: "the untracked files of the workspace could not be read" };
168
+ }
169
+ for (const line of untracked.stdout.split("\n")) {
170
+ const file = line.trim();
171
+ if (file) files.add(file);
172
+ }
173
+
174
+ const scopeFiles = [...files]
175
+ .filter((file) => isReviewablePath(file))
176
+ .sort((a, b) => a.localeCompare(b));
177
+ const numstat = runGit(root, ["diff", "--numstat", revision]);
178
+
179
+ return {
180
+ files: scopeFiles,
181
+ detected: true,
182
+ reason: base ? `the diff since ${base.slice(0, 7)}` : "the diff since HEAD",
183
+ metrics: {
184
+ files: scopeFiles.length,
185
+ modules: countModules(scopeFiles),
186
+ changedLines: numstat.ok ? parseNumstat(numstat.stdout) : 0,
187
+ },
188
+ };
189
+ }
190
+
191
+ /** The default number of files one review chunk covers. */
192
+ export const DEFAULT_CHUNK_SIZE = 12;
193
+
194
+ /**
195
+ * Split a scope into independent review chunks.
196
+ *
197
+ * The chunks follow the modules of the change, so a reviewer of one chunk
198
+ * does not read the same files as a reviewer of another one.
199
+ */
200
+ export function chunkScope(
201
+ files: string[],
202
+ options: { maxFilesPerChunk?: number } = {},
203
+ ): ReviewChunk[] {
204
+ const maxFiles = Math.max(1, options.maxFilesPerChunk ?? DEFAULT_CHUNK_SIZE);
205
+ const groups = new Map<string, string[]>();
206
+ for (const file of files) {
207
+ const key = moduleKey(file);
208
+ const group = groups.get(key) ?? [];
209
+ group.push(file);
210
+ groups.set(key, group);
211
+ }
212
+
213
+ const ordered = [...groups.entries()].sort(
214
+ (a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
215
+ );
216
+
217
+ const chunks: ReviewChunk[] = [];
218
+ let index = 0;
219
+ for (const [key, group] of ordered) {
220
+ for (let start = 0; start < group.length; start += maxFiles) {
221
+ const slice = group.slice(start, start + maxFiles);
222
+ index += 1;
223
+ chunks.push({
224
+ id: `chunk-${index}`,
225
+ label: `${key} (${slice.length} file${slice.length === 1 ? "" : "s"})`,
226
+ files: slice,
227
+ status: "pending",
228
+ });
229
+ }
230
+ }
231
+ return chunks;
232
+ }
233
+
234
+ /**
235
+ * Is the scope large enough to be split?
236
+ *
237
+ * The threshold is measured, not assumed: a scope counts as large when it
238
+ * crosses many modules or many files.
239
+ */
240
+ export function shouldChunkScope(
241
+ metrics: ScopeMetrics,
242
+ thresholds: { maxFiles?: number; maxModules?: number } = {},
243
+ ): boolean {
244
+ const maxFiles = thresholds.maxFiles ?? 12;
245
+ const maxModules = thresholds.maxModules ?? 3;
246
+ return metrics.files > maxFiles || metrics.modules > maxModules;
247
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Review signals.
3
+ *
4
+ * A "review now" signal is any attempt to start a comprehensive review while
5
+ * the implementation is still running. The workflow records the signal and
6
+ * defers the review instead of letting it interrupt the implementation.
7
+ */
8
+
9
+ import type { ReviewSignal } from "./types.js";
10
+
11
+ /**
12
+ * Tool names that start a comprehensive review of the change.
13
+ *
14
+ * Task-level checks (a type error of the file that is edited right now, a
15
+ * failing test of that file) are deliberately absent: they belong to the
16
+ * implementation and stay actionable.
17
+ */
18
+ export const DEFAULT_REVIEW_TOOL_PATTERNS: string[] = [
19
+ "code-review",
20
+ "code_review",
21
+ "code-reviewer",
22
+ "code_reviewer",
23
+ "differential-review",
24
+ "differential_review",
25
+ "variant-analysis",
26
+ "variant_analysis",
27
+ "fp-check",
28
+ "fp_check",
29
+ "semgrep",
30
+ "codeql",
31
+ "durable-review",
32
+ "durable_review",
33
+ "spec-to-code-compliance",
34
+ "spec_to_code_compliance",
35
+ "security-threat-model",
36
+ "security_threat_model",
37
+ "supply-chain",
38
+ "insecure-defaults",
39
+ "sharp-edges",
40
+ "agentic-actions",
41
+ "audit",
42
+ ];
43
+
44
+ /**
45
+ * Command patterns that start a dedicated review runner from a shell.
46
+ *
47
+ * The list is narrow on purpose: a shell command is only a review signal when
48
+ * it runs a review runner, never when it merely mentions one.
49
+ */
50
+ export const DEFAULT_REVIEW_COMMAND_PATTERNS: string[] = [
51
+ "code-review",
52
+ "code-reviewer",
53
+ "differential-review",
54
+ "variant-analysis",
55
+ "fp-check",
56
+ "semgrep",
57
+ "codeql",
58
+ "durable-review",
59
+ "security-review",
60
+ "security-reviewer",
61
+ ];
62
+
63
+ /** The programs that run another program. */
64
+ const LAUNCHERS = new Set([
65
+ "npx",
66
+ "bunx",
67
+ "bun",
68
+ "pnpm",
69
+ "pnpx",
70
+ "yarn",
71
+ "dlx",
72
+ "uvx",
73
+ "pipx",
74
+ "deno",
75
+ "node",
76
+ "python",
77
+ "python3",
78
+ ]);
79
+
80
+ /** The words a launcher uses to name the program it runs. */
81
+ const LAUNCHER_SUBCOMMANDS = new Set(["run", "x", "dlx", "exec"]);
82
+
83
+ /** Normalize a name: lower case, spaces and underscores become dashes. */
84
+ function normalizeName(name: string): string {
85
+ return (name ?? "")
86
+ .trim()
87
+ .toLowerCase()
88
+ .replace(/[\s_]+/g, "-");
89
+ }
90
+
91
+ /**
92
+ * The candidates of a tool name.
93
+ *
94
+ * A tool is identified by its full name and by the name behind its
95
+ * namespace ("mcp__pi__code-review" is "code-review").
96
+ */
97
+ function toolNameCandidates(name: string): string[] {
98
+ const raw = (name ?? "").trim();
99
+ if (!raw) return [];
100
+ const candidates = new Set<string>();
101
+ const add = (value: string): void => {
102
+ const normalized = normalizeName(value);
103
+ if (normalized) candidates.add(normalized);
104
+ };
105
+ add(raw);
106
+ const segments = raw.split(/__|[:/]/).filter(Boolean);
107
+ if (segments.length > 1) add(segments[segments.length - 1]);
108
+ return [...candidates];
109
+ }
110
+
111
+ /**
112
+ * The program a shell segment runs.
113
+ *
114
+ * Only the program matters: a command that mentions a review runner as an
115
+ * argument ("echo code-review") does not run one.
116
+ */
117
+ function commandExecutable(segment: string): string {
118
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
119
+ let index = 0;
120
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index])) index += 1;
121
+ while (
122
+ index < tokens.length &&
123
+ ["sudo", "env", "command", "time", "nohup"].includes(tokens[index].toLowerCase())
124
+ ) {
125
+ index += 1;
126
+ }
127
+ while (index < tokens.length && tokens[index].startsWith("-")) index += 1;
128
+ let executable = tokens[index] ?? "";
129
+ if (!executable) return "";
130
+ if (LAUNCHERS.has(executable.toLowerCase())) {
131
+ index += 1;
132
+ while (index < tokens.length && tokens[index].startsWith("-")) index += 1;
133
+ if (LAUNCHER_SUBCOMMANDS.has((tokens[index] ?? "").toLowerCase())) index += 1;
134
+ executable = tokens[index] ?? "";
135
+ }
136
+ const name = executable.split("/").pop() ?? "";
137
+ return normalizeName(name.replace(/\.(js|mjs|cjs|ts|sh)$/, ""));
138
+ }
139
+
140
+ /** Read the extra patterns a deployment configured. */
141
+ export function parseExtraPatterns(env: NodeJS.ProcessEnv = process.env): string[] {
142
+ const raw = env.PI_TWO_PHASE_REVIEW_TOOLS;
143
+ if (!raw) return [];
144
+ return raw
145
+ .split(",")
146
+ .map((entry) => entry.trim().toLowerCase())
147
+ .filter(Boolean);
148
+ }
149
+
150
+ /**
151
+ * Does the tool name name a comprehensive review?
152
+ *
153
+ * The name is matched exactly: a tool that only contains the name of a
154
+ * review ("read_audit_log") is ordinary work.
155
+ */
156
+ export function isComprehensiveReviewTool(
157
+ toolName: string,
158
+ extraPatterns: string[] = [],
159
+ ): boolean {
160
+ const candidates = toolNameCandidates(toolName);
161
+ if (candidates.length === 0) return false;
162
+ const patterns = [...DEFAULT_REVIEW_TOOL_PATTERNS, ...extraPatterns].map(normalizeName);
163
+ return candidates.some((candidate) => patterns.includes(candidate));
164
+ }
165
+
166
+ /** Does the shell command run a dedicated review runner? */
167
+ export function isReviewCommand(command: string): boolean {
168
+ const text = (command ?? "").toLowerCase();
169
+ if (!text) return false;
170
+ const runners = new Set(DEFAULT_REVIEW_COMMAND_PATTERNS.map(normalizeName));
171
+ const segments = text.split(/(?:\|\||&&|[;\n|])/);
172
+ return segments.some((segment) => {
173
+ const executable = commandExecutable(segment);
174
+ return executable !== "" && runners.has(executable);
175
+ });
176
+ }
177
+
178
+ /** The agent name of a generic dispatch tool, when it has one. */
179
+ export function dispatchAgentName(toolName: string, input: unknown): string {
180
+ if (!["subagent", "task", "dispatch_agent"].includes(toolName)) return "";
181
+ const args = (input ?? {}) as Record<string, unknown>;
182
+ const candidates = [args.agent, args.subagent_type, args.agentName, args.role];
183
+ for (const candidate of candidates) {
184
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
185
+ }
186
+ return "";
187
+ }
188
+
189
+ /** A review request that the harness recognized. */
190
+ export interface ReviewInvocation {
191
+ /** The source of the request (tool name, agent name or command). */
192
+ source: string;
193
+ /** The files the request points at (best effort). */
194
+ files: string[];
195
+ /** One short line describing the request. */
196
+ reason: string;
197
+ }
198
+
199
+ const SOURCE_FILE_PATTERN =
200
+ /(?:^|[\s"'`(=])((?:\.{0,2}\/)?(?:[\w.@+-]+\/)*[\w.@+-]+\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))/g;
201
+
202
+ /** Pull file names out of a tool input (best effort). */
203
+ export function extractFiles(toolName: string, input: unknown): string[] {
204
+ const args = (input ?? {}) as Record<string, unknown>;
205
+ const files = new Set<string>();
206
+
207
+ const add = (value: unknown): void => {
208
+ if (typeof value === "string" && value.trim()) files.add(value.trim());
209
+ };
210
+ const addList = (value: unknown): void => {
211
+ if (Array.isArray(value)) {
212
+ for (const entry of value) add(entry);
213
+ return;
214
+ }
215
+ add(value);
216
+ };
217
+
218
+ for (const key of ["files", "paths", "path", "file", "target", "targets", "scope"]) {
219
+ addList(args[key]);
220
+ }
221
+
222
+ if (toolName === "bash" && typeof args.command === "string") {
223
+ const matches = args.command.matchAll(SOURCE_FILE_PATTERN);
224
+ for (const match of matches) {
225
+ if (match[1]) files.add(match[1]);
226
+ }
227
+ }
228
+
229
+ return [...files].slice(0, 200);
230
+ }
231
+
232
+ /**
233
+ * Recognize a request to start a comprehensive review.
234
+ *
235
+ * Returns `null` when the call is an ordinary task-level check.
236
+ */
237
+ export function detectReviewInvocation(
238
+ toolName: string,
239
+ input: unknown,
240
+ extraPatterns: string[] = [],
241
+ ): ReviewInvocation | null {
242
+ if (isComprehensiveReviewTool(toolName, extraPatterns)) {
243
+ return {
244
+ source: toolName,
245
+ files: extractFiles(toolName, input),
246
+ reason: `a comprehensive review was requested with ${toolName}`,
247
+ };
248
+ }
249
+
250
+ const agent = dispatchAgentName(toolName, input);
251
+ if (agent && isComprehensiveReviewTool(agent, extraPatterns)) {
252
+ return {
253
+ source: agent,
254
+ files: extractFiles(toolName, input),
255
+ reason: `a comprehensive review was requested with the ${agent} reviewer`,
256
+ };
257
+ }
258
+
259
+ if (toolName === "bash") {
260
+ const command = (input as Record<string, unknown> | null)?.command;
261
+ if (typeof command === "string" && isReviewCommand(command)) {
262
+ return {
263
+ source: "bash",
264
+ files: extractFiles(toolName, input),
265
+ reason: `a review runner was started: ${command.trim().slice(0, 160)}`,
266
+ };
267
+ }
268
+ }
269
+
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * A negation of a review request.
275
+ *
276
+ * The negation is read per sentence, so "do not review yet" does not silence
277
+ * a later "please review the diff".
278
+ */
279
+ const NEGATED_REVIEW =
280
+ /\b(?:do not|don'?t|does not|doesn'?t|no|without|skip|avoid|postpone|defer|later|not yet|never)\b[^.!?\n]{0,40}\b(?:review|audit)\b/i;
281
+
282
+ /** The ways a human asks for a review. */
283
+ const AFFIRMATIVE_REVIEW: RegExp[] = [
284
+ /\b(?:please\s+)?(?:run|do|perform|start|begin|execute|launch|carry out|give)\s+(?:a\s+|an\s+|the\s+)?(?:comprehensive\s+|full\s+|complete\s+|code\s+|security\s+)?(?:review|audit)\b/i,
285
+ /\b(?:can|could|would|will|shall)\s+you\s+(?:please\s+)?(?:review|audit)\b/i,
286
+ /\b(?:review|audit)\s+(?:the|this|that|my|our|it|everything|all|each)\b/i,
287
+ /^\s*(?:please\s+)?(?:review|audit)\b/i,
288
+ /\b(?:review|audit)\s+(?:now|again)\b/i,
289
+ ];
290
+
291
+ /**
292
+ * Did the human ask for a review explicitly?
293
+ *
294
+ * The phase order never overrides the intent of the user: an explicit request
295
+ * runs the review now. A sentence that talks about a review without asking
296
+ * for one is not a request.
297
+ */
298
+ export function explicitReviewRequest(message: string): boolean {
299
+ if (!message) return false;
300
+ const sentences = message.split(/(?<=[.!?])\s+/);
301
+ return sentences.some((sentence) => {
302
+ if (NEGATED_REVIEW.test(sentence)) return false;
303
+ return AFFIRMATIVE_REVIEW.some((pattern) => pattern.test(sentence));
304
+ });
305
+ }
306
+
307
+ /** The identity of a signal: the same signal is never recorded twice. */
308
+ export function signalKey(signal: ReviewSignal): string {
309
+ const files = [...signal.files].sort().join(",");
310
+ return `${signal.source}::${files}::${signal.reason.trim().toLowerCase()}`;
311
+ }
312
+
313
+ /** Add a signal, ignoring an identical one. */
314
+ export function mergeSignals(
315
+ signals: ReviewSignal[],
316
+ signal: ReviewSignal,
317
+ limit = 50,
318
+ ): ReviewSignal[] {
319
+ const key = signalKey(signal);
320
+ if (signals.some((entry) => signalKey(entry) === key)) return signals;
321
+ const next = [...signals, signal];
322
+ return next.length > limit ? next.slice(next.length - limit) : next;
323
+ }
324
+
325
+ /** The merged view of every recorded signal. */
326
+ export interface SignalSummary {
327
+ sources: string[];
328
+ files: string[];
329
+ reason: string;
330
+ }
331
+
332
+ /** Merge the recorded signals into one scope. */
333
+ export function summarizeSignals(signals: ReviewSignal[]): SignalSummary {
334
+ const sources = [...new Set(signals.map((signal) => signal.source))];
335
+ const files = [...new Set(signals.flatMap((signal) => signal.files))];
336
+ const reason = [...new Set(signals.map((signal) => signal.reason.trim()).filter(Boolean))].join(
337
+ "; ",
338
+ );
339
+ return { sources, files, reason };
340
+ }