@bridge_gpt/mcp-server 0.2.67 → 0.2.68

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 (114) hide show
  1. package/README.md +178 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +829 -0
  7. package/build/claude-settings.js +18 -0
  8. package/build/cli-help.js +21 -0
  9. package/build/command-assets-doctor.js +24 -70
  10. package/build/command-provisioning.js +29 -5
  11. package/build/commands.generated.js +8 -8
  12. package/build/conductor/bridge-api-client.js +714 -0
  13. package/build/conductor/command-drift-production.js +92 -0
  14. package/build/conductor/command-drift.js +89 -0
  15. package/build/conductor/doctor.js +69 -26
  16. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  17. package/build/conductor/epic-implementer-cli.js +76 -22
  18. package/build/conductor/gate-discovery.js +890 -0
  19. package/build/conductor/install-doctor.js +15 -3
  20. package/build/conductor/json-output.js +63 -0
  21. package/build/conductor/readiness-cli.js +95 -10
  22. package/build/conductor/readiness-sections.js +369 -3
  23. package/build/conductor/readiness.js +211 -8
  24. package/build/conductor/recovery-cli.js +110 -4
  25. package/build/conductor/recovery-operations.js +99 -1
  26. package/build/connect-bitbucket-api.js +41 -6
  27. package/build/connect-bitbucket.js +106 -33
  28. package/build/credential-store.js +207 -47
  29. package/build/docs.generated.js +57 -3
  30. package/build/doctor.js +92 -1
  31. package/build/drive-epic.js +45 -8
  32. package/build/epic-coherence-input.test-fixtures.js +4 -3
  33. package/build/epic-implementer-bin.js +1374 -25
  34. package/build/epic-workarounds-path.js +39 -0
  35. package/build/executor/cli.js +30 -0
  36. package/build/executor/completion-contract.js +18 -5
  37. package/build/executor/deps.js +4 -1
  38. package/build/executor/gate-qualification-job.js +499 -0
  39. package/build/executor/guarded-fast-forward-push.js +129 -0
  40. package/build/executor/integration-job.js +122 -8
  41. package/build/executor/job-errors.js +8 -1
  42. package/build/executor/job-runner.js +589 -47
  43. package/build/executor/likely-tests.js +290 -0
  44. package/build/executor/live-worker-registry.js +1 -1
  45. package/build/executor/observation.js +31 -2
  46. package/build/executor/packaged-recipe.js +106 -0
  47. package/build/executor/permissions.js +115 -28
  48. package/build/executor/platform.js +1 -1
  49. package/build/executor/preflight.js +61 -12
  50. package/build/executor/process-containment.js +36 -12
  51. package/build/executor/process.js +15 -7
  52. package/build/executor/prompt-spec.js +33 -1
  53. package/build/executor/recently-failed-tests-file.js +71 -0
  54. package/build/executor/reconcile-overlap-job.js +41 -23
  55. package/build/executor/required-verification.js +1 -1
  56. package/build/executor/required-work-ledger.js +105 -17
  57. package/build/executor/retirement.js +69 -0
  58. package/build/executor/runner.js +114 -11
  59. package/build/executor/test-clock.js +15 -0
  60. package/build/executor/types.js +32 -0
  61. package/build/executor/viewer-tabs.js +58 -8
  62. package/build/executor/worker-command.js +23 -7
  63. package/build/executor/worker-diagnostics.js +37 -0
  64. package/build/executor/worker-finalization.js +31 -5
  65. package/build/executor/worker-guard-hook.js +179 -25
  66. package/build/executor/worker-log.js +4 -3
  67. package/build/executor/worker-verification-guidance.js +37 -0
  68. package/build/executor/worktree.js +88 -11
  69. package/build/first-build-advisory.js +97 -0
  70. package/build/generated-assets-advisory.js +14 -0
  71. package/build/generated-assets-catalog.js +66 -0
  72. package/build/generated-assets-context.js +46 -0
  73. package/build/generated-assets-dependencies.js +26 -0
  74. package/build/generated-assets-doctor.js +50 -0
  75. package/build/generated-assets-health.js +65 -0
  76. package/build/generated-assets-host.js +17 -0
  77. package/build/generated-assets-inventory.js +17 -0
  78. package/build/generated-assets-paths.js +60 -0
  79. package/build/generated-assets-publisher.js +133 -0
  80. package/build/generated-assets-remedy.js +25 -0
  81. package/build/generated-assets-repair.js +72 -0
  82. package/build/generated-assets-session.js +71 -0
  83. package/build/generated-assets-test-support.js +35 -0
  84. package/build/index.js +964 -768
  85. package/build/init.js +54 -177
  86. package/build/install-bridge-conductor-gate.js +1372 -0
  87. package/build/install-bridge-conductor.js +289 -36
  88. package/build/install-bridge.js +544 -37
  89. package/build/mcp-install-state.js +50 -4
  90. package/build/merge-workflow-install.js +3 -4
  91. package/build/pipeline-utils.js +6 -2
  92. package/build/pipelines.generated.js +22 -142
  93. package/build/plan-execution-ledger.js +2 -32
  94. package/build/plane/manifest.js +16 -1
  95. package/build/plane/preflight.js +54 -1
  96. package/build/printed-remedy-commands.js +16 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/repair-assets.js +93 -0
  99. package/build/run-unit-tests-launcher.js +130 -49
  100. package/build/secret-redaction.js +33 -0
  101. package/build/setup-epic.js +108 -3
  102. package/build/start-tickets-prereqs.js +70 -16
  103. package/build/start-tickets.js +29 -5
  104. package/build/tool-surface-gating.js +36 -0
  105. package/build/upgrade-cli.js +55 -1
  106. package/build/version.generated.js +3 -3
  107. package/build/worker-guard-hook-bin.js +3 -2
  108. package/build/worker-guard-hook-main.js +1 -1
  109. package/build/worktree-core.js +15 -2
  110. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  111. package/docs/EPIC-IMPLEMENTER.md +68 -2
  112. package/package.json +3 -3
  113. package/pipelines/greenfield-setup.json +4 -128
  114. 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
- * Branch names that a BRANCH-SHAPED deny glob would match (BAPI-1020).
120
+ * The push commands a worker uses for its OWN branch (BAPI-1397, F7).
121
121
  *
122
- * Two of the fifteen new rules — `Bash(git push:* main*)` and
123
- * `Bash(git push:* epic/*)` — deny by branch shape rather than by command. If a
124
- * worker's OWN branch ever matched one, the static deny would block the push the
125
- * guard is required to allow, and AC-10 ("it still pushes its own branch") would
126
- * break at push time with an error naming a rule nobody connected to the branch
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 workerBranchCollidesWithDenyGlob(branch) {
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
- return trimmed === "main" || trimmed.startsWith("main") || trimmed.startsWith("epic/");
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 — the branch-shaped-glob collision check. Advisory rather than
384
- // blocking, matching this function's established fail-open posture: the worker
385
- // can still do most of its job, and killing the spawn would trade a push failure
386
- // for a total one. It is loud where it matters — the warning names the condition
387
- // at PROVISIONING time, so an operator reads it before the push fails, instead of
388
- // decoding a deny rule from a push error twenty minutes later.
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
- workerBranchCollidesWithDenyGlob(options.workerBranch);
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 a branch-shaped deny glob") is the whole diagnostic.
407
- advisory.push("the job branch matches a branch-shaped executor deny glob (git push to main/ or " +
408
- "epic/); the worker's own push would be denied by settings permissions.deny, " +
409
- "which the PreToolUse guard cannot override — re-cut the branch before relying " +
410
- "on this worktree");
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;