@bridge_gpt/mcp-server 0.2.67 → 0.2.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -51
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +1362 -0
- package/build/check-update-cli.js +74 -0
- package/build/claude-settings.js +102 -2
- package/build/cli-help.js +27 -0
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +9 -9
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +198 -4
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1369 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +20 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/http-client.js +43 -4
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1109 -695
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +722 -252
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +44 -5
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +32 -57
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +398 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local, read-only discovery. Optional .bridge/verification-hints.json is
|
|
3
|
+
* {version:1,pins:[{paths:["exact/file", "directory/"],tests:["reference"]}]}.
|
|
4
|
+
* Exact paths match exactly; directory prefixes match at directory boundaries.
|
|
5
|
+
* Optional .conductor/recently-failed-tests.json is
|
|
6
|
+
* {version:1,tests:["most-recent-first reference"],truncated:false}.
|
|
7
|
+
* Child 3 writes recent failures under the worker worktree before each spawn.
|
|
8
|
+
* References are data only, never commands. Priority: recent failures, ordered
|
|
9
|
+
* pins, sorted inferred tracked tests; exact deduplication precedes the limit.
|
|
10
|
+
* Missing/malformed/unreadable/truncated sources degrade advisory coverage.
|
|
11
|
+
*/
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { constants } from "node:fs";
|
|
14
|
+
import { lstat, open } from "node:fs/promises";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
export const RECENTLY_FAILED_TESTS_PATH = ".conductor/recently-failed-tests.json";
|
|
17
|
+
export const RECENTLY_FAILED_TESTS_MAX_COUNT = 20;
|
|
18
|
+
export const TEST_REFERENCE_MAX_LENGTH = 512;
|
|
19
|
+
export const MAX_OUTPUT_REFERENCES = 100;
|
|
20
|
+
export const MAX_SCAN_FILE_BYTES = 256 * 1024;
|
|
21
|
+
export const MAX_SCAN_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
22
|
+
export const MAX_CANDIDATE_FILES = 10_000;
|
|
23
|
+
export const MAX_OPTIONAL_JSON_BYTES = 256 * 1024;
|
|
24
|
+
export const GIT_TIMEOUT_MS = 10_000;
|
|
25
|
+
export const GIT_MAX_STDOUT_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
export const DISCOVERY_NOTES = {
|
|
27
|
+
invalid_arguments: "Likely-tests discovery unavailable: expected only --base and a full commit SHA.",
|
|
28
|
+
git_unavailable: "Likely-tests git discovery unavailable; retain available targeted verification.",
|
|
29
|
+
hints_missing: "Optional verification hints are absent.",
|
|
30
|
+
data_unavailable: "Optional discovery data is malformed or unreadable.",
|
|
31
|
+
skipped_entries: "Invalid discovery entries were skipped.",
|
|
32
|
+
scan_unavailable: "Some test candidates could not be read safely.",
|
|
33
|
+
truncated: "Likely-tests coverage is truncated by a resource bound.",
|
|
34
|
+
empty: "No likely tests discovered; retain targeted verification.",
|
|
35
|
+
};
|
|
36
|
+
export function isFullCommitSha(value) { return /^[a-fA-F0-9]{40}$/.test(value); }
|
|
37
|
+
function object(value) {
|
|
38
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
39
|
+
? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
export function validTestReference(value) {
|
|
42
|
+
return typeof value === "string" && value.trim().length > 0 &&
|
|
43
|
+
value.length <= TEST_REFERENCE_MAX_LENGTH && !/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(value);
|
|
44
|
+
}
|
|
45
|
+
function validRepoPath(value, directory = false) {
|
|
46
|
+
const candidate = directory && value.endsWith("/") ? value.slice(0, -1) : value;
|
|
47
|
+
return !!candidate && !path.isAbsolute(candidate) && !/[:\\\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(candidate) &&
|
|
48
|
+
candidate.split("/").every(p => p !== "" && p !== "." && p !== "..");
|
|
49
|
+
}
|
|
50
|
+
function validReference(value) {
|
|
51
|
+
if (!validTestReference(value))
|
|
52
|
+
return false;
|
|
53
|
+
const file = value.split("::")[0];
|
|
54
|
+
const fileShaped = value.includes("/") || /\.(?:py|[cm]?[jt]sx?)(?:::|$)/.test(value) || value.includes("::");
|
|
55
|
+
return !fileShaped || validRepoPath(file);
|
|
56
|
+
}
|
|
57
|
+
export function parseRecentlyFailedTests(value) {
|
|
58
|
+
const doc = object(value);
|
|
59
|
+
if (!doc || doc.version !== 1 || !Array.isArray(doc.tests) || typeof doc.truncated !== "boolean")
|
|
60
|
+
return { available: false };
|
|
61
|
+
const tests = [];
|
|
62
|
+
let skipped = false;
|
|
63
|
+
for (const test of doc.tests) {
|
|
64
|
+
if (!validReference(test)) {
|
|
65
|
+
skipped = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (tests.length < RECENTLY_FAILED_TESTS_MAX_COUNT)
|
|
69
|
+
tests.push(test);
|
|
70
|
+
}
|
|
71
|
+
return { available: true, value: { version: 1, tests,
|
|
72
|
+
truncated: doc.truncated || doc.tests.length > RECENTLY_FAILED_TESTS_MAX_COUNT }, skipped };
|
|
73
|
+
}
|
|
74
|
+
function parseHints(value) {
|
|
75
|
+
const doc = object(value);
|
|
76
|
+
if (!doc || doc.version !== 1 || !Array.isArray(doc.pins))
|
|
77
|
+
return undefined;
|
|
78
|
+
const pins = [];
|
|
79
|
+
let skipped = false;
|
|
80
|
+
for (const entry of doc.pins) {
|
|
81
|
+
const pin = object(entry);
|
|
82
|
+
if (!pin || !Array.isArray(pin.paths) || !Array.isArray(pin.tests)) {
|
|
83
|
+
skipped = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const paths = pin.paths.filter((p) => typeof p === "string" && validRepoPath(p, true));
|
|
87
|
+
const tests = pin.tests.filter(validReference);
|
|
88
|
+
if (paths.length !== pin.paths.length || tests.length !== pin.tests.length)
|
|
89
|
+
skipped = true;
|
|
90
|
+
if (paths.length && tests.length)
|
|
91
|
+
pins.push({ paths, tests });
|
|
92
|
+
else
|
|
93
|
+
skipped = true;
|
|
94
|
+
}
|
|
95
|
+
return { pins, skipped };
|
|
96
|
+
}
|
|
97
|
+
/** No-follow and fstat protect the final component; lstat every ancestor first.
|
|
98
|
+
* Reads at most limit+1 bytes even if a file grows between stat and read.
|
|
99
|
+
*/
|
|
100
|
+
async function containedText(root, relative, limit) {
|
|
101
|
+
if (!validRepoPath(relative))
|
|
102
|
+
throw new Error("invalid_path");
|
|
103
|
+
let current = root;
|
|
104
|
+
for (const part of relative.split("/")) {
|
|
105
|
+
current = path.join(current, part);
|
|
106
|
+
const info = await lstat(current);
|
|
107
|
+
if (info.isSymbolicLink())
|
|
108
|
+
throw new Error("symlink");
|
|
109
|
+
if (current !== path.join(root, relative) && !info.isDirectory())
|
|
110
|
+
throw new Error("ancestor");
|
|
111
|
+
}
|
|
112
|
+
const handle = await open(current, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
113
|
+
try {
|
|
114
|
+
const info = await handle.stat();
|
|
115
|
+
if (!info.isFile())
|
|
116
|
+
throw new Error("not_regular");
|
|
117
|
+
if (info.size > limit)
|
|
118
|
+
throw new Error("bound");
|
|
119
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
120
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
121
|
+
if (bytesRead > limit)
|
|
122
|
+
throw new Error("bound");
|
|
123
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await handle.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function boundedGit(cwd, argv) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
execFile("git", argv, { cwd, shell: false, timeout: GIT_TIMEOUT_MS,
|
|
132
|
+
maxBuffer: GIT_MAX_STDOUT_BYTES, encoding: "utf8" }, (error, stdout) => {
|
|
133
|
+
if (error)
|
|
134
|
+
reject(new Error("git_unavailable"));
|
|
135
|
+
else
|
|
136
|
+
resolve(stdout);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function nulPaths(output) {
|
|
141
|
+
if (output && !output.endsWith("\0"))
|
|
142
|
+
throw new Error("git_unavailable");
|
|
143
|
+
return output.split("\0").filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
const ordinal = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
146
|
+
function testCandidate(file) {
|
|
147
|
+
const base = path.posix.basename(file);
|
|
148
|
+
return /^test_.*\.py$|^.*_test\.py$|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(base);
|
|
149
|
+
}
|
|
150
|
+
/** Discover declared Python source roots without importing any project code. */
|
|
151
|
+
async function pythonRoots(root, read) {
|
|
152
|
+
const roots = [""];
|
|
153
|
+
for (const file of ["pyproject.toml", "pytest.ini", "setup.cfg"]) {
|
|
154
|
+
try {
|
|
155
|
+
const text = await read(root, file, MAX_SCAN_FILE_BYTES);
|
|
156
|
+
for (const match of text.matchAll(/(?:pythonpath|package-dir|package_dir)\s*=\s*([^\n]+)/g)) {
|
|
157
|
+
for (const token of match[1].matchAll(/["']([^"']+)["']|\b([a-zA-Z][\w/-]*)\b/g)) {
|
|
158
|
+
const value = token[1] ?? token[2];
|
|
159
|
+
if (validRepoPath(value))
|
|
160
|
+
roots.push(value + "/");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch { /* absent runner declarations are normal */ }
|
|
165
|
+
}
|
|
166
|
+
return [...new Set(roots)];
|
|
167
|
+
}
|
|
168
|
+
export async function resolveLikelyTests(options) {
|
|
169
|
+
const references = [];
|
|
170
|
+
const diagnostics = new Set();
|
|
171
|
+
const add = (items) => {
|
|
172
|
+
for (const item of items)
|
|
173
|
+
if (!references.includes(item))
|
|
174
|
+
references.push(item);
|
|
175
|
+
};
|
|
176
|
+
const finish = () => {
|
|
177
|
+
if (references.length > MAX_OUTPUT_REFERENCES)
|
|
178
|
+
diagnostics.add("truncated");
|
|
179
|
+
if (!references.length)
|
|
180
|
+
diagnostics.add("empty");
|
|
181
|
+
return { references: references.slice(0, MAX_OUTPUT_REFERENCES), diagnostics: [...diagnostics] };
|
|
182
|
+
};
|
|
183
|
+
if (!isFullCommitSha(options.comparisonSha)) {
|
|
184
|
+
diagnostics.add("invalid_arguments");
|
|
185
|
+
return finish();
|
|
186
|
+
}
|
|
187
|
+
const git = options.git ?? boundedGit;
|
|
188
|
+
const read = options.readText ?? containedText;
|
|
189
|
+
let root;
|
|
190
|
+
try {
|
|
191
|
+
root = (await git(options.cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
192
|
+
if (!path.isAbsolute(root))
|
|
193
|
+
throw new Error("root");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
diagnostics.add("git_unavailable");
|
|
197
|
+
return finish();
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const raw = await read(root, RECENTLY_FAILED_TESTS_PATH, MAX_OPTIONAL_JSON_BYTES);
|
|
201
|
+
const recent = parseRecentlyFailedTests(JSON.parse(raw));
|
|
202
|
+
if (!recent.available)
|
|
203
|
+
diagnostics.add("data_unavailable");
|
|
204
|
+
else {
|
|
205
|
+
add(recent.value.tests);
|
|
206
|
+
if (recent.skipped)
|
|
207
|
+
diagnostics.add("skipped_entries");
|
|
208
|
+
if (recent.value.truncated)
|
|
209
|
+
diagnostics.add("truncated");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code !== "ENOENT")
|
|
214
|
+
diagnostics.add("data_unavailable");
|
|
215
|
+
}
|
|
216
|
+
let hints;
|
|
217
|
+
try {
|
|
218
|
+
hints = parseHints(JSON.parse(await read(root, ".bridge/verification-hints.json", MAX_OPTIONAL_JSON_BYTES)));
|
|
219
|
+
if (!hints)
|
|
220
|
+
diagnostics.add("data_unavailable");
|
|
221
|
+
else if (hints.skipped)
|
|
222
|
+
diagnostics.add("skipped_entries");
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
diagnostics.add(error.code === "ENOENT" ? "hints_missing" : "data_unavailable");
|
|
226
|
+
}
|
|
227
|
+
let changed;
|
|
228
|
+
try {
|
|
229
|
+
const verified = (await git(root, ["rev-parse", "--verify", `${options.comparisonSha}^{commit}`])).trim();
|
|
230
|
+
if (!isFullCommitSha(verified))
|
|
231
|
+
throw new Error("commit");
|
|
232
|
+
const paths = nulPaths(await git(root, ["diff", "--name-only", "-z", `${options.comparisonSha}..HEAD`]));
|
|
233
|
+
changed = paths.filter(p => validRepoPath(p));
|
|
234
|
+
if (changed.length !== paths.length)
|
|
235
|
+
diagnostics.add("skipped_entries");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
diagnostics.add("git_unavailable");
|
|
239
|
+
return finish();
|
|
240
|
+
}
|
|
241
|
+
for (const pin of hints?.pins ?? []) {
|
|
242
|
+
if (pin.paths.some(p => changed.some(c => p.endsWith("/") ? c.startsWith(p) : c === p)))
|
|
243
|
+
add(pin.tests);
|
|
244
|
+
}
|
|
245
|
+
let tracked;
|
|
246
|
+
try {
|
|
247
|
+
const paths = nulPaths(await git(root, ["ls-files", "-z"]));
|
|
248
|
+
tracked = paths.filter(p => validRepoPath(p));
|
|
249
|
+
if (tracked.length !== paths.length)
|
|
250
|
+
diagnostics.add("skipped_entries");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
diagnostics.add("git_unavailable");
|
|
254
|
+
return finish();
|
|
255
|
+
}
|
|
256
|
+
const candidates = tracked.filter(testCandidate).sort(ordinal);
|
|
257
|
+
if (candidates.length > MAX_CANDIDATE_FILES)
|
|
258
|
+
diagnostics.add("truncated");
|
|
259
|
+
const roots = await pythonRoots(root, read);
|
|
260
|
+
const literals = new Set(changed);
|
|
261
|
+
for (const file of changed) {
|
|
262
|
+
if (/\.[cm]?[jt]sx?$/.test(file)) {
|
|
263
|
+
literals.add(path.posix.basename(file));
|
|
264
|
+
literals.add(path.posix.basename(file).replace(/\.[cm]?[jt]sx?$/, ".js"));
|
|
265
|
+
}
|
|
266
|
+
if (file.endsWith(".py"))
|
|
267
|
+
for (const source of roots) {
|
|
268
|
+
if (file.startsWith(source))
|
|
269
|
+
literals.add(file.slice(source.length, -3).replaceAll("/", ".").replace(/\.__init__$/, ""));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let scanned = 0;
|
|
273
|
+
for (const candidate of candidates.slice(0, MAX_CANDIDATE_FILES)) {
|
|
274
|
+
try {
|
|
275
|
+
const text = await read(root, candidate, MAX_SCAN_FILE_BYTES);
|
|
276
|
+
const size = Buffer.byteLength(text);
|
|
277
|
+
if (scanned + size > MAX_SCAN_TOTAL_BYTES) {
|
|
278
|
+
diagnostics.add("truncated");
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
scanned += size;
|
|
282
|
+
if ([...literals].some(literal => literal && text.includes(literal)))
|
|
283
|
+
add([candidate]);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
diagnostics.add(error.message === "bound" ? "truncated" : "scan_unavailable");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return finish();
|
|
290
|
+
}
|
|
@@ -43,7 +43,7 @@ export function createLiveWorkerRegistry() {
|
|
|
43
43
|
return;
|
|
44
44
|
alreadyRequested.add(controller);
|
|
45
45
|
try {
|
|
46
|
-
controller.requestTermination();
|
|
46
|
+
controller.requestTermination("executor_shutdown");
|
|
47
47
|
}
|
|
48
48
|
catch {
|
|
49
49
|
// Fail-open, and deliberately silent. A controller that throws is a defect
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createWorkerDiagnosticsState } from "./worker-diagnostics.js";
|
|
1
2
|
/**
|
|
2
3
|
* Collect load-bearing git telemetry from a worktree. Individual git failures
|
|
3
4
|
* degrade the telemetry (fields omitted) rather than throwing — a degraded
|
|
@@ -603,7 +604,10 @@ export const MAX_BUFFERED_ADVISORY_LINE_CHARS = 1_048_576;
|
|
|
603
604
|
* on bad input, so ordinary success never depends on it either.
|
|
604
605
|
*/
|
|
605
606
|
export function createObservationState(deps, options) {
|
|
607
|
+
const diagnostics = createWorkerDiagnosticsState();
|
|
606
608
|
let git = {};
|
|
609
|
+
let publication;
|
|
610
|
+
let publicationInvalid = false;
|
|
607
611
|
const advisory = {};
|
|
608
612
|
let exitCode;
|
|
609
613
|
let attemptStartSha;
|
|
@@ -681,6 +685,7 @@ export function createObservationState(deps, options) {
|
|
|
681
685
|
}
|
|
682
686
|
};
|
|
683
687
|
const consumeLine = (line) => {
|
|
688
|
+
diagnostics.observeLine(line);
|
|
684
689
|
if (options.advisoryParserEnabled)
|
|
685
690
|
consumeAdvisoryLine(line);
|
|
686
691
|
if (terminalCapacityParserEnabled)
|
|
@@ -689,8 +694,6 @@ export function createObservationState(deps, options) {
|
|
|
689
694
|
return {
|
|
690
695
|
recordStdout(chunk) {
|
|
691
696
|
advisory.last_stdout_at = new Date(deps.now()).toISOString();
|
|
692
|
-
if (!options.advisoryParserEnabled && !terminalCapacityParserEnabled)
|
|
693
|
-
return;
|
|
694
697
|
if (typeof chunk !== "string" || chunk.length === 0)
|
|
695
698
|
return;
|
|
696
699
|
// BAPI-828: line assembly replaces the previous per-chunk `split("\n")`.
|
|
@@ -753,8 +756,34 @@ export function createObservationState(deps, options) {
|
|
|
753
756
|
git() {
|
|
754
757
|
return git;
|
|
755
758
|
},
|
|
759
|
+
allocateAttemptLog(identity, allocated) {
|
|
760
|
+
diagnostics.allocateLog(identity, allocated);
|
|
761
|
+
},
|
|
762
|
+
workerDiagnostics() { return diagnostics.snapshot(); },
|
|
763
|
+
recordPublication(fact) {
|
|
764
|
+
if (publicationInvalid)
|
|
765
|
+
return;
|
|
766
|
+
const valid = (sha) => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha);
|
|
767
|
+
if (!valid(fact.from_sha) || !valid(fact.to_sha) || fact.from_sha === fact.to_sha)
|
|
768
|
+
return;
|
|
769
|
+
if (!publication) {
|
|
770
|
+
publication = { ...fact };
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (publication.jobId !== fact.jobId || publication.branch !== fact.branch ||
|
|
774
|
+
publication.to_sha !== fact.from_sha) {
|
|
775
|
+
publication = undefined;
|
|
776
|
+
publicationInvalid = true;
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
publication = { ...publication, to_sha: fact.to_sha };
|
|
780
|
+
},
|
|
756
781
|
snapshot() {
|
|
757
782
|
const residue = { ...git };
|
|
783
|
+
if (publication) {
|
|
784
|
+
const observed = { from_sha: publication.from_sha, to_sha: publication.to_sha };
|
|
785
|
+
residue.published_head = observed;
|
|
786
|
+
}
|
|
758
787
|
if (advisory.phase_hint)
|
|
759
788
|
residue.phase_hint = advisory.phase_hint;
|
|
760
789
|
if (advisory.last_stdout_at)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packaged-recipe delivery for executor workers (BAPI-1392).
|
|
3
|
+
*
|
|
4
|
+
* Command provisioning is fill-only (BAPI-664): a command file the customer
|
|
5
|
+
* already committed under `.claude/commands/` is a customer asset and is never
|
|
6
|
+
* refreshed, so a worker that resolves `/implement-ticket` itself would run a
|
|
7
|
+
* stale committed copy. This module supplies the CURRENT packaged recipe instead,
|
|
8
|
+
* by expanding it into the worker prompt before the adapter builds the launch.
|
|
9
|
+
*
|
|
10
|
+
* DELIVERY PATH: prompt expansion. Both prompt transports (`-p <prompt>` argv and
|
|
11
|
+
* the held-stdin stream-json first message) carry exactly the prompt the runner
|
|
12
|
+
* hands the adapter, so one expansion reaches both with no launcher-option
|
|
13
|
+
* dependence. Nothing is written under the worker worktree and no Git index flag
|
|
14
|
+
* is touched; the worktree's command files keep the bytes provisioning left.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IS RECOGNIZED (and nothing else):
|
|
17
|
+
* 1. A LEADING invocation — the prompt's first token is `/<name>` — which is how
|
|
18
|
+
* the synthesized `/implement-ticket <KEY> --auto` arrives. Its arguments are
|
|
19
|
+
* everything after the name, exactly as the slash command would receive them
|
|
20
|
+
* (so an appended PR-base instruction stays part of `$ARGUMENTS`). A BAPI-1367
|
|
21
|
+
* continuation notice may precede it; the notice's own mention of the command
|
|
22
|
+
* is never an invocation.
|
|
23
|
+
* 2. A NESTED invocation written as ``Run `/<name> <args>` `` — the shape the
|
|
24
|
+
* server-built `spec_review` prompt uses for `/review-ticket`. The span is
|
|
25
|
+
* replaced by a pointer and the recipe is appended once, below the prompt.
|
|
26
|
+
* Quoted ticket text, arbitrary slash-prefixed prose and a bare mention are left
|
|
27
|
+
* alone. A name is recognized only when `<name>.md` is a key of the packaged
|
|
28
|
+
* catalog, and never when the command is in the job's customized list (those keep
|
|
29
|
+
* the customer's committed copy). A prompt with no recognized invocation is
|
|
30
|
+
* returned byte-identical. Delivered recipes are NOT expanded recursively.
|
|
31
|
+
*/
|
|
32
|
+
import { COMMANDS } from "../commands.generated.js";
|
|
33
|
+
/** Marker pair of the BAPI-1367 continuation notice (see `job-runner.ts`). */
|
|
34
|
+
const CONTINUATION_NOTICE_START = "CONTINUATION: this is a retry.";
|
|
35
|
+
const CONTINUATION_NOTICE_END = "The original instructions follow unchanged.\n\n";
|
|
36
|
+
const NAME = "[a-z][a-z0-9-]*";
|
|
37
|
+
const LEADING_RE = new RegExp(`^/(${NAME})(?=\\s|$)`);
|
|
38
|
+
const NESTED_RE = new RegExp("\\bRun `/(" + NAME + ")(?: ([^`\\n]*))?`", "g");
|
|
39
|
+
function isPackaged(name, catalog) {
|
|
40
|
+
return Object.prototype.hasOwnProperty.call(catalog, `${name}.md`);
|
|
41
|
+
}
|
|
42
|
+
/** Drop a leading `---` frontmatter block, as the slash-command loader does. */
|
|
43
|
+
function stripFrontmatter(text) {
|
|
44
|
+
const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(text);
|
|
45
|
+
return match ? text.slice(match[0].length) : text;
|
|
46
|
+
}
|
|
47
|
+
/** `$ARGUMENTS` substitution with a function replacer so `$&`-style bytes survive. */
|
|
48
|
+
function renderRecipe(recipe, args) {
|
|
49
|
+
return stripFrontmatter(recipe).replace(/\$ARGUMENTS/g, () => args);
|
|
50
|
+
}
|
|
51
|
+
function undeliverable(reason, command) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason,
|
|
55
|
+
command,
|
|
56
|
+
error: `packaged recipe for /${command} could not be delivered (reason=${reason})`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function resolvePackagedRecipes(prompt, customizedCommands, catalog = COMMANDS) {
|
|
60
|
+
const customized = new Set(customizedCommands);
|
|
61
|
+
const deliverable = (name) => isPackaged(name, catalog) && !customized.has(name);
|
|
62
|
+
// Offset of the "original instructions" after an optional continuation notice.
|
|
63
|
+
let offset = 0;
|
|
64
|
+
if (prompt.startsWith(CONTINUATION_NOTICE_START)) {
|
|
65
|
+
const end = prompt.indexOf(CONTINUATION_NOTICE_END);
|
|
66
|
+
if (end >= 0)
|
|
67
|
+
offset = end + CONTINUATION_NOTICE_END.length;
|
|
68
|
+
}
|
|
69
|
+
const head = prompt.slice(0, offset);
|
|
70
|
+
const body = prompt.slice(offset);
|
|
71
|
+
// 1. Leading invocation: the whole body becomes the recipe.
|
|
72
|
+
const leading = LEADING_RE.exec(body);
|
|
73
|
+
if (leading && deliverable(leading[1])) {
|
|
74
|
+
const name = leading[1];
|
|
75
|
+
const args = body.slice(leading[0].length).replace(/^\s+/, "");
|
|
76
|
+
const recipe = renderRecipe(catalog[`${name}.md`], args);
|
|
77
|
+
if (recipe.trim().length === 0)
|
|
78
|
+
return undeliverable("empty_recipe", name);
|
|
79
|
+
return { ok: true, prompt: `${head}${recipe}`, delivered: [name] };
|
|
80
|
+
}
|
|
81
|
+
// 2. Nested invocations: one appended block per command name.
|
|
82
|
+
const blocks = new Map(); // name -> args
|
|
83
|
+
for (const m of body.matchAll(NESTED_RE)) {
|
|
84
|
+
const name = m[1];
|
|
85
|
+
if (!deliverable(name))
|
|
86
|
+
continue;
|
|
87
|
+
const args = m[2] ?? "";
|
|
88
|
+
const seen = blocks.get(name);
|
|
89
|
+
if (seen !== undefined && seen !== args)
|
|
90
|
+
return undeliverable("conflicting_arguments", name);
|
|
91
|
+
blocks.set(name, args);
|
|
92
|
+
}
|
|
93
|
+
if (blocks.size === 0)
|
|
94
|
+
return { ok: true, prompt, delivered: [] };
|
|
95
|
+
const rewritten = body.replace(NESTED_RE, (whole, name, args) => blocks.has(name)
|
|
96
|
+
? `Run the packaged /${name} recipe given below with arguments \`${args ?? ""}\``
|
|
97
|
+
: whole);
|
|
98
|
+
const appended = [];
|
|
99
|
+
for (const [name, args] of blocks) {
|
|
100
|
+
const recipe = renderRecipe(catalog[`${name}.md`], args);
|
|
101
|
+
if (recipe.trim().length === 0)
|
|
102
|
+
return undeliverable("empty_recipe", name);
|
|
103
|
+
appended.push(`\n\n=== PACKAGED RECIPE /${name} (arguments: ${args}) ===\n${recipe}\n=== END PACKAGED RECIPE /${name} ===`);
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, prompt: `${head}${rewritten}${appended.join("")}`, delivered: [...blocks.keys()] };
|
|
106
|
+
}
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* fallback is available for CLIs where settings-deny is not enforced.
|
|
37
37
|
*/
|
|
38
38
|
import path from "node:path";
|
|
39
|
-
import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
|
|
39
|
+
import { mergeClaudeSettingsWithAutoMemoryDisabled, mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
|
|
40
40
|
/**
|
|
41
41
|
* The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
|
|
42
42
|
* `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
|
|
@@ -117,26 +117,96 @@ export function executorDenyRules(inputs) {
|
|
|
117
117
|
];
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
120
|
+
* The push commands a worker uses for its OWN branch (BAPI-1397, F7).
|
|
121
121
|
*
|
|
122
|
-
*
|
|
123
|
-
* `
|
|
124
|
-
*
|
|
125
|
-
* guard
|
|
126
|
-
*
|
|
127
|
-
* name. The hook cannot rescue it: a PreToolUse allow does not override a
|
|
128
|
-
* settings-level deny.
|
|
129
|
-
*
|
|
130
|
-
* Today the collision is unreachable — worker branches come from `wt.branch`, the
|
|
131
|
-
* Worktrunk `feature/<KEY>` branch, and `epic/<EPIC>` appears only as a merge base
|
|
132
|
-
* — but nothing wrote that invariant down. This does, and it fails LOUDLY at
|
|
133
|
-
* provisioning time rather than mysteriously at push time.
|
|
122
|
+
* These are the forms the worker instructions direct (`checkpoint-work.md`:
|
|
123
|
+
* `git push origin <branch>`; `commit-and-push.md`: `git push -u origin
|
|
124
|
+
* <branch>`) plus the bare upstream push, and every one is a form the PreToolUse
|
|
125
|
+
* worker guard allows for the job's own branch (`worker-guard-hook.ts`,
|
|
126
|
+
* `evaluateGitPush`).
|
|
134
127
|
*/
|
|
135
|
-
export function
|
|
128
|
+
export function workerOwnBranchPushCommands(branch) {
|
|
136
129
|
const trimmed = branch.trim();
|
|
137
130
|
if (trimmed.length === 0)
|
|
131
|
+
return [];
|
|
132
|
+
return [`git push origin ${trimmed}`, `git push -u origin ${trimmed}`, "git push"];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Does a Claude Code `Bash(...)` permission rule match `command`? (BAPI-1397, F7)
|
|
136
|
+
*
|
|
137
|
+
* Mirrors the rule grammar of the installed Claude Code (checked against 2.1.287):
|
|
138
|
+
*
|
|
139
|
+
* - content ending in `:*` is a PREFIX rule — `Bash(git push:*)` matches
|
|
140
|
+
* `git push` and anything after it;
|
|
141
|
+
* - otherwise content with an unescaped `*` is a WILDCARD rule — the whole
|
|
142
|
+
* command must match, with each `*` matching any text (a single trailing ` *`
|
|
143
|
+
* also matches nothing at all). A `:*` in the MIDDLE is not special here: in
|
|
144
|
+
* `Bash(git push:* epic/*)` the `:` is a literal character, so that rule
|
|
145
|
+
* never matches `git push origin epic/X`;
|
|
146
|
+
* - anything else is an EXACT rule.
|
|
147
|
+
*
|
|
148
|
+
* Non-`Bash` rules never match a command.
|
|
149
|
+
*/
|
|
150
|
+
export function claudeBashRuleMatchesCommand(rule, command) {
|
|
151
|
+
const ruleMatch = /^Bash\((.*)\)$/s.exec(rule.trim());
|
|
152
|
+
if (!ruleMatch)
|
|
138
153
|
return false;
|
|
139
|
-
|
|
154
|
+
const content = ruleMatch[1].trim();
|
|
155
|
+
const cmd = command.trim();
|
|
156
|
+
const prefix = /^(.+):\*$/s.exec(content);
|
|
157
|
+
if (prefix) {
|
|
158
|
+
const head = prefix[1];
|
|
159
|
+
return cmd === head || cmd.startsWith(`${head} `);
|
|
160
|
+
}
|
|
161
|
+
let pattern = "";
|
|
162
|
+
let literal = "";
|
|
163
|
+
let stars = 0;
|
|
164
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
165
|
+
const ch = content[i];
|
|
166
|
+
// `\*` is a literal star and `\\` a literal backslash, as in Claude Code.
|
|
167
|
+
if (ch === "\\" && (content[i + 1] === "*" || content[i + 1] === "\\")) {
|
|
168
|
+
pattern += `\\${content[i + 1]}`;
|
|
169
|
+
literal += content[i + 1];
|
|
170
|
+
i += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (ch === "*") {
|
|
174
|
+
pattern += ".*";
|
|
175
|
+
stars += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
pattern += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
179
|
+
literal += ch;
|
|
180
|
+
}
|
|
181
|
+
if (stars === 0)
|
|
182
|
+
return cmd === literal;
|
|
183
|
+
if (stars === 1 && pattern.endsWith(" .*"))
|
|
184
|
+
pattern = `${pattern.slice(0, -3)}( .*)?`;
|
|
185
|
+
return new RegExp(`^${pattern}$`, "s").test(cmd);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Would one of `denyRules` block the worker's OWN push? (BAPI-1397, F7)
|
|
189
|
+
*
|
|
190
|
+
* Replaces the BAPI-1020 branch-NAME check, which warned for any `main*` or
|
|
191
|
+
* `epic/` branch. That was wrong in practice: the correction worker's push to
|
|
192
|
+
* `epic/` succeeded despite the warning, because the provisioned
|
|
193
|
+
* `Bash(git push:* epic/*)` rule does not match the push command the worker
|
|
194
|
+
* actually runs. This builds those real push commands and tests them against the
|
|
195
|
+
* deny rules with Claude Code's own matching grammar, so it warns only when a
|
|
196
|
+
* rule really matches.
|
|
197
|
+
*/
|
|
198
|
+
export function workerOwnPushDeniedByRules(branch, denyRules) {
|
|
199
|
+
const commands = workerOwnBranchPushCommands(branch);
|
|
200
|
+
return denyRules.some((rule) => typeof rule === "string" &&
|
|
201
|
+
commands.some((command) => claudeBashRuleMatchesCommand(rule, command)));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Would the executor's OWN deny rules block this branch's push? Kept for callers
|
|
205
|
+
* of the BAPI-1020 name; it now asks the command-form question of
|
|
206
|
+
* {@link workerOwnPushDeniedByRules} against {@link executorDenyRules}.
|
|
207
|
+
*/
|
|
208
|
+
export function workerBranchCollidesWithDenyGlob(branch) {
|
|
209
|
+
return workerOwnPushDeniedByRules(branch, executorDenyRules({ baseBranch: "", worktreePath: "", homedir: "" }));
|
|
140
210
|
}
|
|
141
211
|
function asObject(value) {
|
|
142
212
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -363,8 +433,22 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
363
433
|
worktreePath,
|
|
364
434
|
homedir: deps.homedir(),
|
|
365
435
|
};
|
|
436
|
+
// BAPI-1397 (F7): the deny list the worktree will actually carry — the executor
|
|
437
|
+
// rules plus any operator-authored entries already in the file. Captured from
|
|
438
|
+
// the merge; when the merge never runs (a malformed file is left untouched) the
|
|
439
|
+
// executor's own rules stand in.
|
|
440
|
+
let effectiveDeny = executorDenyRules(inputs);
|
|
366
441
|
const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
|
|
367
442
|
let merged = mergeExecutorDenySettings(existing, inputs);
|
|
443
|
+
const mergedDeny = merged.permissions?.deny;
|
|
444
|
+
if (Array.isArray(mergedDeny))
|
|
445
|
+
effectiveDeny = mergedDeny;
|
|
446
|
+
// BAPI-1397 (F6) — a worker session must never write the OWNER's Claude
|
|
447
|
+
// auto-memory. Merged through the same malformed-safe write as the deny
|
|
448
|
+
// rules, so a malformed settings file is still never clobbered and a
|
|
449
|
+
// re-provisioned worktree is unchanged. The job runner also sets the
|
|
450
|
+
// environment form at spawn, so a failed write here does not re-enable it.
|
|
451
|
+
merged = mergeClaudeSettingsWithAutoMemoryDisabled(merged);
|
|
368
452
|
// BAPI-1020 — the worker guard is registered UNCONDITIONALLY, before and
|
|
369
453
|
// independently of the conditional fallback below. PreToolUse hooks fire in
|
|
370
454
|
// EVERY permission mode, bypass included, which is what makes the guard hold
|
|
@@ -380,14 +464,16 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
380
464
|
// may already exist from an earlier provisioning, so it runs even when the
|
|
381
465
|
// merge-write was refused or failed.
|
|
382
466
|
const exclude = await provisionWorktreeSettingsExclude(worktreePath, deps);
|
|
383
|
-
// BAPI-1020 —
|
|
384
|
-
// blocking, matching this function's established
|
|
385
|
-
// can still do most of its job, and killing the
|
|
386
|
-
// for a total one. It is loud where it
|
|
387
|
-
// at PROVISIONING time,
|
|
388
|
-
//
|
|
467
|
+
// BAPI-1020 / BAPI-1397 (F7) — would a deny rule block the worker's own push?
|
|
468
|
+
// Advisory rather than blocking, matching this function's established
|
|
469
|
+
// fail-open posture: the worker can still do most of its job, and killing the
|
|
470
|
+
// spawn would trade a push failure for a total one. It is loud where it
|
|
471
|
+
// matters — at PROVISIONING time, before the push fails. Since BAPI-1397 it is
|
|
472
|
+
// decided by matching the worker's REAL push commands against the deny rules
|
|
473
|
+
// with Claude Code's grammar, not by the branch name's shape: an `epic/` branch
|
|
474
|
+
// alone no longer warns, because no provisioned rule matches its push.
|
|
389
475
|
const collidingBranch = typeof options.workerBranch === "string" &&
|
|
390
|
-
|
|
476
|
+
workerOwnPushDeniedByRules(options.workerBranch, effectiveDeny);
|
|
391
477
|
// `blocking` and `advisory` are kept apart on purpose. A blocking warning
|
|
392
478
|
// flips `ok` (the established contract, which existing callers and tests
|
|
393
479
|
// read); an advisory one rides along on `ok: true` so a degraded-but-
|
|
@@ -403,11 +489,12 @@ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
|
|
|
403
489
|
advisory.push(exclude.warning);
|
|
404
490
|
if (collidingBranch) {
|
|
405
491
|
// The branch NAME is not echoed: this text travels into worker logs, and the
|
|
406
|
-
// condition ("matches
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
"
|
|
410
|
-
"
|
|
492
|
+
// condition ("a deny rule matches the worker's own push") is the whole
|
|
493
|
+
// diagnostic.
|
|
494
|
+
advisory.push("a settings permissions.deny rule matches the worker's own git push command for " +
|
|
495
|
+
"the job branch; the worker's own push would be denied by settings " +
|
|
496
|
+
"permissions.deny, which the PreToolUse guard cannot override — remove the rule " +
|
|
497
|
+
"or re-cut the branch before relying on this worktree");
|
|
411
498
|
}
|
|
412
499
|
if (blocking.length > 0) {
|
|
413
500
|
return { ok: false, warning: [...blocking, ...advisory].join("; ") };
|
|
@@ -188,7 +188,7 @@ export function startExecutorSleepAssertion(deps) {
|
|
|
188
188
|
if (releasing)
|
|
189
189
|
return;
|
|
190
190
|
releasing = true;
|
|
191
|
-
controller.requestTermination();
|
|
191
|
+
controller.requestTermination("executor_abort");
|
|
192
192
|
// Await the child so a caller that releases and then exits does not race the
|
|
193
193
|
// teardown, and so `pmset -g assertions` is observably clean afterwards.
|
|
194
194
|
await settled;
|