@bridge_gpt/mcp-server 0.2.44 → 0.2.46

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.
@@ -35,6 +35,28 @@ export const CONDUCT_EPIC_DEFAULT_SOFT_SECONDS = 3600;
35
35
  export const CONDUCT_EPIC_DEFAULT_HARD_SECONDS = 10800;
36
36
  /** Retained journal lines per ticket. The oldest are dropped first. */
37
37
  export const CONDUCT_EPIC_MAX_JOURNAL_LINES = 50;
38
+ /**
39
+ * BAPI-915 — how many verdictless review observations, at ONE head, the pilot
40
+ * makes before it commits a terminal disposition.
41
+ *
42
+ * A fixed CONSTANT, deliberately: not a policy key, not an operator-editable
43
+ * checkpoint field, and not a `deadlines` entry. The pilot has no `RunPolicy`
44
+ * — that is the whole reason v2's `review_policy.verdictless_disposition` is
45
+ * unreachable from here — and R14 rule 2's spirit is that a new knob needs a
46
+ * declared home. This has none, so it is not a knob. What IS operator-settable
47
+ * is the disposition itself, on the `review_state` done-gate condition, which
48
+ * already has a declared home that both the pilot and the merge path read.
49
+ *
50
+ * Six observations at the documented five-minute tick cadence is roughly thirty
51
+ * minutes — comfortably inside `deadlines.hard_seconds` (10800), so the ceiling
52
+ * is what an operator actually sees rather than a three-hour `stalled`.
53
+ *
54
+ * It is EXPORTED because `cli.ts` publishes it in the status snapshot: the
55
+ * command compares two numbers it read from that snapshot instead of carrying a
56
+ * bound in prose, matching "every value is absolute, computed from the Stage 2
57
+ * snapshot".
58
+ */
59
+ export const CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING = 6;
38
60
  /** The closed per-ticket status vocabulary. */
39
61
  export const CONDUCT_EPIC_TICKET_STATUSES = [
40
62
  "pending",
@@ -88,10 +110,13 @@ function isRecord(value) {
88
110
  return typeof value === "object" && value !== null && !Array.isArray(value);
89
111
  }
90
112
  /**
91
- * Supply `null` for parse-request fields a pre-BAPI-825 version-1 ticket does
92
- * not carry, returning a NEW document rather than mutating the input.
113
+ * Supply defaults for per-ticket fields an older version-1 ticket does not
114
+ * carry, returning a NEW document rather than mutating the input.
93
115
  *
94
- * The two fields were added without incrementing
116
+ * Two generations of additive fields go through here: the pre-BAPI-825
117
+ * parse-request pair, and the BAPI-915 verdictless-observation pair
118
+ * (`review_verdictless_observations` → `0`, `review_verdictless_for_sha` →
119
+ * `null`). Both were added without incrementing
95
120
  * {@link CONDUCT_EPIC_CHECKPOINT_VERSION}, because bumping the version would
96
121
  * make every checkpoint written by an in-flight pilot run instantly
97
122
  * `unsupported-version` — a hard error whose documented recovery is a human
@@ -101,7 +126,9 @@ function isRecord(value) {
101
126
  *
102
127
  * Only ABSENT keys are filled. A key that is present but malformed is left
103
128
  * exactly as it is, so validation still rejects it rather than having it
104
- * quietly repaired into a legal value.
129
+ * quietly repaired into a legal value. That distinction is the point: filling an
130
+ * absent key is reading an old document, while repairing a present bad one would
131
+ * be inventing state an operator never wrote and then acting on it.
105
132
  */
106
133
  export function normalizeConductEpicCheckpoint(value) {
107
134
  if (!isRecord(value) || !Array.isArray(value.tickets))
@@ -122,6 +149,14 @@ export function normalizeConductEpicCheckpoint(value) {
122
149
  normalized.parse_requested_at = null;
123
150
  if (!("parse_requested_for_sha" in normalized))
124
151
  normalized.parse_requested_for_sha = null;
152
+ // BAPI-915: a checkpoint written before the verdictless ceiling existed
153
+ // reads as "nothing observed yet, bound to no head", not as invalid state.
154
+ if (!("review_verdictless_observations" in normalized)) {
155
+ normalized.review_verdictless_observations = 0;
156
+ }
157
+ if (!("review_verdictless_for_sha" in normalized)) {
158
+ normalized.review_verdictless_for_sha = null;
159
+ }
125
160
  return normalized;
126
161
  }),
127
162
  };
@@ -194,6 +229,15 @@ function validateTicket(value, index) {
194
229
  return fail(`${where}.${key} must be a non-empty string or null`);
195
230
  }
196
231
  }
232
+ // BAPI-915: same posture — ABSENT is impossible here because
233
+ // `normalizeConductEpicCheckpoint` filled it, so anything reaching this check
234
+ // is present, and a present malformed value is REJECTED rather than repaired.
235
+ if (!isCount(value.review_verdictless_observations)) {
236
+ return fail(`${where}.review_verdictless_observations must be a non-negative integer`);
237
+ }
238
+ if (!isNullableText(value.review_verdictless_for_sha)) {
239
+ return fail(`${where}.review_verdictless_for_sha must be a non-empty string or null`);
240
+ }
197
241
  if (!Array.isArray(value.journal) || value.journal.some((line) => typeof line !== "string")) {
198
242
  return fail(`${where}.journal must be an array of strings`);
199
243
  }
@@ -401,6 +445,8 @@ export function createInitialConductEpicCheckpoint(input) {
401
445
  counters: { sessions_spawned: 0, plan_generations_observed: 0, merge_attempts: 0 },
402
446
  parse_requested_at: null,
403
447
  parse_requested_for_sha: null,
448
+ review_verdictless_observations: 0,
449
+ review_verdictless_for_sha: null,
404
450
  journal: [],
405
451
  })),
406
452
  counters: { iterations: 0, merges: 0 },
@@ -40,7 +40,7 @@ import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultSta
40
40
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
41
41
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
42
42
  import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
43
- import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
43
+ import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
44
44
  import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
45
45
  import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
46
46
  import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
@@ -75,6 +75,8 @@ const TICKET_FIELDS = [
75
75
  "spawned_at",
76
76
  "parse_requested_at",
77
77
  "parse_requested_for_sha",
78
+ "review_verdictless_observations",
79
+ "review_verdictless_for_sha",
78
80
  "respawns",
79
81
  "conflict_attempts",
80
82
  "counters.sessions_spawned",
@@ -1404,6 +1406,22 @@ export async function runConductEpicStatus(deps, options) {
1404
1406
  let doneGateRequired = null;
1405
1407
  let reviewOptedIn = false;
1406
1408
  let reviewSource = null;
1409
+ let reviewDisposition = null;
1410
+ // BAPI-915. An UNREADABLE review policy is not an absent one.
1411
+ //
1412
+ // Before this, an unparseable `done_gate_config` yielded `conditions: []`,
1413
+ // which set `reviewOptedIn = false` — so the pilot read a malformed review
1414
+ // policy as "no review opt-in" and merged on CI alone. That is precisely the
1415
+ // accidental fail-open the Python `INVALID` sentinel exists to prevent, and it
1416
+ // cannot coexist with this ticket's invariant that no malformed input can
1417
+ // select `fail_open`.
1418
+ //
1419
+ // The parser's own `reason` draws the line: `unset` and `disabled` are a
1420
+ // genuine operator opt-out and stay opted out; `malformed` and every
1421
+ // `invalid: …` reason are a policy we could not read, and surface as
1422
+ // opted-in / unavailable / config-invalid so the command's Row 10 handles them
1423
+ // and — having no readable condition — parks under the default disposition.
1424
+ let reviewConfigInvalid = false;
1407
1425
  if (access !== null) {
1408
1426
  const setup = await getEffectiveSupervisorSetup(access, deps.fetchImpl);
1409
1427
  if (!setup.ok) {
@@ -1411,6 +1429,14 @@ export async function runConductEpicStatus(deps, options) {
1411
1429
  }
1412
1430
  else {
1413
1431
  const gate = parseDoneGateConfig(setup.value.done_gate_config);
1432
+ if (gate.reason === "malformed" || gate.reason.startsWith("invalid:")) {
1433
+ reviewConfigInvalid = true;
1434
+ reviewOptedIn = true;
1435
+ // No readable condition exists, so no disposition may be read from one.
1436
+ // `null` leaves the command's effective default at `park`.
1437
+ reviewSource = null;
1438
+ reviewDisposition = null;
1439
+ }
1414
1440
  for (const condition of gate.conditions) {
1415
1441
  if (condition.type === "required_ci_checks_green") {
1416
1442
  doneGateRequired = [...condition.required_checks];
@@ -1418,6 +1444,7 @@ export async function runConductEpicStatus(deps, options) {
1418
1444
  else if (condition.type === "review_state") {
1419
1445
  reviewOptedIn = true;
1420
1446
  reviewSource = condition.source;
1447
+ reviewDisposition = condition.verdictless_disposition ?? null;
1421
1448
  }
1422
1449
  }
1423
1450
  }
@@ -1425,21 +1452,52 @@ export async function runConductEpicStatus(deps, options) {
1425
1452
  const ci = access === null || pr?.head_sha == null
1426
1453
  ? null
1427
1454
  : await collectCiFacts(deps, access, pr.head_sha, doneGateRequired, checkpoint.ci_last_poll, probeErrors);
1455
+ // BAPI-915: the three additional fields are appended to every shape this
1456
+ // sub-object takes, so a consumer never has to branch on whether they exist.
1457
+ const reviewPolicyFacts = {
1458
+ verdictless_disposition: reviewDisposition,
1459
+ verdictless_ceiling: CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING,
1460
+ config_invalid: reviewConfigInvalid,
1461
+ };
1428
1462
  let review = {
1429
1463
  opted_in: reviewOptedIn,
1430
1464
  source: reviewSource,
1431
1465
  available: null,
1432
1466
  verdict: null,
1433
1467
  head_sha: null,
1468
+ ...reviewPolicyFacts,
1434
1469
  };
1435
- if (access !== null && reviewOptedIn && pr?.number != null) {
1470
+ // A config we could not read is reported as opted-in and UNAVAILABLE without
1471
+ // any read being attempted: there is no condition to evaluate a review
1472
+ // against, so `available: false` is the honest answer rather than `null`.
1473
+ if (reviewConfigInvalid) {
1474
+ review = {
1475
+ opted_in: true,
1476
+ source: null,
1477
+ available: false,
1478
+ verdict: null,
1479
+ head_sha: null,
1480
+ ...reviewPolicyFacts,
1481
+ };
1482
+ }
1483
+ else if (access !== null && reviewOptedIn && pr?.number != null) {
1436
1484
  const status = await getPrReviewStatus(access, pr.number, deps.fetchImpl);
1437
1485
  if (!status.ok) {
1438
1486
  probeErrors.push({ probe: "review", reason: status.error });
1439
- review = { opted_in: true, source: reviewSource, available: null, verdict: null, head_sha: null };
1487
+ review = {
1488
+ opted_in: true,
1489
+ source: reviewSource,
1490
+ available: null,
1491
+ verdict: null,
1492
+ head_sha: null,
1493
+ ...reviewPolicyFacts,
1494
+ };
1440
1495
  }
1441
1496
  else {
1442
- review = normalizeReviewStatus(status.value, reviewOptedIn, reviewSource);
1497
+ review = {
1498
+ ...normalizeReviewStatus(status.value, reviewOptedIn, reviewSource),
1499
+ ...reviewPolicyFacts,
1500
+ };
1443
1501
  }
1444
1502
  }
1445
1503
  let parse = null;
@@ -1744,6 +1802,8 @@ export function projectConductEpicTicketFacts(ticket, discoveredBranch) {
1744
1802
  spawned_at: ticket.spawned_at,
1745
1803
  parse_requested_at: ticket.parse_requested_at,
1746
1804
  parse_requested_for_sha: ticket.parse_requested_for_sha,
1805
+ review_verdictless_observations: ticket.review_verdictless_observations,
1806
+ review_verdictless_for_sha: ticket.review_verdictless_for_sha,
1747
1807
  respawns: ticket.respawns,
1748
1808
  conflict_attempts: ticket.conflict_attempts,
1749
1809
  counters: { ...ticket.counters },
@@ -2064,7 +2124,11 @@ function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
2064
2124
  return null;
2065
2125
  }
2066
2126
  case "parse_requested_at":
2067
- case "parse_requested_for_sha": {
2127
+ case "parse_requested_for_sha":
2128
+ // BAPI-915: the head the verdictless counter is bound to. Reuses this
2129
+ // nullable-string branch so a tick can clear the binding as deliberately as
2130
+ // it sets it — the counter resets by writing `null` here, not by hand-editing.
2131
+ case "review_verdictless_for_sha": {
2068
2132
  // The durable half of the post-merge causal barrier (BAPI-825/A2). Both
2069
2133
  // follow the module's nullable-string convention so a tick can clear them
2070
2134
  // as deliberately as it sets them — an operator re-running a parse writes
@@ -2080,7 +2144,11 @@ function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
2080
2144
  return null;
2081
2145
  }
2082
2146
  case "respawns":
2083
- case "conflict_attempts": {
2147
+ case "conflict_attempts":
2148
+ // BAPI-915: the head-bound verdictless observation count. Reuses this
2149
+ // non-negative-integer branch, so a negative or non-integer value is
2150
+ // refused by the same parser that guards every other per-ticket counter.
2151
+ case "review_verdictless_observations": {
2084
2152
  const parsed = parseIntegerField(value);
2085
2153
  if (parsed === null)
2086
2154
  return `${name} must be a non-negative integer.`;
@@ -10,7 +10,7 @@
10
10
  * `repo + pr_number + head_sha` binding. This module is pure (no I/O) and never
11
11
  * throws for caller input.
12
12
  */
13
- import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
13
+ import { DEFAULT_GATE_NAME, REQUIRED_CI_CHECKS_GREEN, REVIEW_STATE, VERDICTLESS_DISPOSITIONS, normalizeCheckName, normalizeSha, stableJsonHash, } from "./git-ci-types.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Config parsing
16
16
  // ---------------------------------------------------------------------------
@@ -148,6 +148,20 @@ function parseReviewStateCondition(entry) {
148
148
  return null;
149
149
  condition.logic = "and";
150
150
  }
151
+ // BAPI-915: exact match against the closed set, with the same all-or-nothing
152
+ // posture every field above uses. No case folding and no trimming, so "Park"
153
+ // and " park" are refused rather than repaired — a value we had to guess at is
154
+ // not a value the operator configured. A malformed spelling invalidates the
155
+ // WHOLE entry (`return null`) instead of dropping just this field, which is
156
+ // what makes "no malformed input can ever select fail_open" true: the only way
157
+ // to reach `fail_open` is to spell it exactly.
158
+ if (entry.verdictless_disposition !== undefined) {
159
+ if (typeof entry.verdictless_disposition !== "string" ||
160
+ !VERDICTLESS_DISPOSITIONS.includes(entry.verdictless_disposition)) {
161
+ return null;
162
+ }
163
+ condition.verdictless_disposition = entry.verdictless_disposition;
164
+ }
151
165
  // For combination source, at least one sub-source must be active; otherwise
152
166
  // the condition would trivially pass with an empty failures array (fail-open).
153
167
  if (condition.source === "combination") {
@@ -241,6 +255,18 @@ export function parseDoneGateConfig(value) {
241
255
  r.min_approvals = c.min_approvals;
242
256
  if (c.logic !== undefined)
243
257
  r.logic = c.logic;
258
+ // BAPI-915: the disposition belongs in the gate IDENTITY because the
259
+ // identity is signed into the merge action key (`merge-pull-request.ts`,
260
+ // `makeMergeActionKey`), and the disposition changes WHAT THE GATE ADMITS.
261
+ // Two configs that differ only in disposition are two different gates and
262
+ // must not share an action key.
263
+ //
264
+ // The `!== undefined` guard is load-bearing in the other direction: every
265
+ // config written before this field existed must hash BYTE-IDENTICALLY to
266
+ // what it hashed before, or every stored action key changes at once. That
267
+ // is why the effective `park` default is never materialized here.
268
+ if (c.verdictless_disposition !== undefined)
269
+ r.verdictless_disposition = c.verdictless_disposition;
244
270
  return r;
245
271
  }),
246
272
  });
@@ -453,6 +479,18 @@ export function normalizeReviewSnapshot(raw) {
453
479
  * Deterministically evaluate a review-state condition against a normalized snapshot.
454
480
  * Pure and fail-closed: a null snapshot or any unknown/missing source yields
455
481
  * `passed: false` and `changesRequested: false` (never a false positive).
482
+ *
483
+ * **`condition.verdictless_disposition` is deliberately IGNORED here (BAPI-915).**
484
+ * This function answers one question — does the review evidence satisfy the
485
+ * configured source? — and it decides `gate.met` for the v2 done-gate producer
486
+ * path. A disposition is a decision about what to do when the answer is "no", not
487
+ * a different answer; letting it flip a verdictless evaluation to `passed` would
488
+ * silently turn a v2 done gate fail-open for consumers that never asked for one,
489
+ * and those consumers cannot tell an evaluated pass from a waived one. The two
490
+ * merge seams apply the disposition themselves, where the waiver is recorded and
491
+ * visible. A test pins this inertness — a given snapshot must yield the same
492
+ * `{passed, changesRequested, reason}` under `park`, under `fail_open`, and with
493
+ * the field absent.
456
494
  */
457
495
  export function evaluateReviewCondition(condition, snapshot) {
458
496
  if (snapshot === null) {
@@ -143,3 +143,37 @@ export function stableJsonHash(value) {
143
143
  const json = JSON.stringify(canonical) ?? "null";
144
144
  return createHash("sha256").update(json).digest("hex");
145
145
  }
146
+ /**
147
+ * BAPI-915 — the terminal disposition a bounded verdictless-review ceiling commits.
148
+ *
149
+ * `park` is the pre-BAPI-915 behaviour and the effective default whenever the
150
+ * field is absent, so every configuration written before this existed keeps its
151
+ * exact terminal handling. `fail_open` is the CONFIGURED exception: it treats a
152
+ * readable, same-head, verdictless review as opted out for that decision and lets
153
+ * CI evidence alone carry the merge.
154
+ *
155
+ * The two literals are exported as named constants so no consumer spells them
156
+ * inline — a typo in one consumer would otherwise silently disagree with the
157
+ * parser about which disposition is configured. The Python mirror lives in
158
+ * `api/library/epic_conductor/conductor_readiness.py` and must carry the same
159
+ * closed set.
160
+ */
161
+ export const VERDICTLESS_DISPOSITION_PARK = "park";
162
+ export const VERDICTLESS_DISPOSITION_FAIL_OPEN = "fail_open";
163
+ /** Every accepted disposition spelling, for exact-match validation. */
164
+ export const VERDICTLESS_DISPOSITIONS = [
165
+ VERDICTLESS_DISPOSITION_PARK,
166
+ VERDICTLESS_DISPOSITION_FAIL_OPEN,
167
+ ];
168
+ /**
169
+ * BAPI-915 — the TypeScript mirror of v2's
170
+ * `MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON`, whose Python owner is
171
+ * `api/models/epic_run.py`. ONE spelling exists across both conductors on
172
+ * purpose: an operator greps a single string to find every merge that advanced
173
+ * on a waiver rather than on a verdict.
174
+ *
175
+ * It never claims the pull request was reviewed or approved — it names the
176
+ * degradation exactly. `tests/pytest/library/epic_conductor/` carries the
177
+ * cross-language pin that keeps this literal equal to the Python one.
178
+ */
179
+ export const MERGE_REVIEW_WAIVED_BY_DEGRADATION_REASON = "review_waived_verdictless_fail_open";
@@ -3,5 +3,5 @@
3
3
  export const DOCS = {
4
4
  "docs/mcp-tool-integrations.md": "# MCP tool integrations — the human \"why\" behind the capability report\n\nThis catalog is **explanatory prose only**. It exists so the `/install-bridge`\ncapability report can cite a human-readable \"why\" for each gate. It is **not** a\nsource of truth for gating: the server computes every `locked_tools` /\n`unlocked_tools` membership decision itself and the agent must never recompute a\ntool's dependencies from this document.\n\n**Authoritative source of gating.** The enforced rules — which tools are blocked,\nwhich are degraded, and what each requires — live in\n`api/library/vcs/vcs_route_operations.py`:\n\n- `VCS_ROUTE_REQUIREMENTS` — routes that **BLOCK** (are unavailable) without a\n VCS connection.\n- `VCS_ROUTE_WARNINGS` — routes that **DEGRADE** (stay usable, but without\n codebase context) without a VCS connection.\n- `NEVER_GATED_ROUTE_KEYS` — routes that are never gated on any integration.\n- `INDEX_REQUIRED_ROUTE_KEYS`, `INDEX_REQUIRED_BRAINSTORM_MODES`,\n `CREATE_DOC_CODEBASE_CONTEXT_DOC_TYPES`, `CREATE_DOC_WARN_DOC_TYPES` — the\n conditional \"requires a successful code index\" dimension.\n- The resolver helpers `get_required_vcs_operation()`, `get_warn_vcs_operation()`,\n and `requires_successful_index()` are the authoritative functions that decide a\n case. The capability report is derived from these; this catalog explains them.\n\n## Reading the capability report\n\nEach tool entry the server returns has the exact shape\n`{tool, effect, missing, semantics}`:\n\n- **`effect`**\n - **`BLOCK`** — the tool is **unavailable** until every listed dependency is\n met. It will refuse to run without them.\n - **`DEGRADE`** — the tool is **usable right now**, but **without codebase\n context** (it cannot ground its output in your repository). Connecting the\n listed dependency upgrades it from \"works blind\" to \"works with full context\".\n A `DEGRADE` tool is never \"failed\".\n- **`missing`** — the server-computed dependency identifiers still needed:\n integration ids such as `github_app` / `vcs_access_token`, and the synthetic\n `code_index` (a successful repository index).\n- **`semantics`**\n - **`all_of`** — every id in `missing` is required.\n - **`any_of`** — the VCS-provider candidates in `missing` are alternatives:\n **either** `github_app` **or** `vcs_access_token` satisfies the VCS\n requirement (this is the \"provider unknown\" case). When `code_index` also\n appears, it remains separately required — `semantics` describes only the VCS\n provider candidates, and a code index is always mandatory in addition.\n\nThe three readiness dimensions `configured` / `learned` / `indexed` are reported\nindependently. `indexed` may be `true`, `false`, or `null` — a `null` means the\nindex status could not be confirmed and must **not** be read as \"indexed\".\n\n## The integrations\n\n| Integration id | What it is | What it unlocks |\n| --- | --- | --- |\n| `jira` | Jira API access | Ticket reads/writes, estimation and review automations, status transitions. |\n| `github_app` | GitHub App installation | Pull requests, code review, and private-repo parsing on GitHub projects. |\n| `vcs_access_token` | VCS access token | Pull requests, code review, and private-repo parsing on Bitbucket projects. |\n| `vcs_webhook` | VCS webhook secret | Merge webhooks and CI follow-up triggers. |\n| `code_index` | A successful repository index | Codebase-grounded planning, architecture, reimplementation, and technical/discovery brainstorms. Produced by `/parse-repository`. |\n\nA project's `github_app` **or** `vcs_access_token` provides the VCS connection;\nwhich one applies depends on the project's version-control system. When the\nproject's provider is unknown, either credential satisfies the requirement — the\nreport expresses that as `semantics: any_of`.\n\n## The gates, by capability\n\n### Pull requests and CI (BLOCK on VCS)\n\nTools like `create_pull_request`, `resolve_ci_checks`, `poll_ci_checks`, and\n`materialize_fresh_base` are **unavailable** (`BLOCK`) until a VCS connection is\nconfigured. They act directly on the version-control host, so without a\nconnection there is nothing for them to talk to.\n\n### Repository indexing and maps (BLOCK on VCS)\n\n`parse_repository` and `regenerate_directory_map` need a VCS connection to read\nthe repository. They **BLOCK** until VCS is connected.\n\n### Codebase-grounded generation (BLOCK on VCS **and** a code index)\n\nPlanning and architecture tools — `generate_plan_direct`,\n`generate_architecture_direct`, `request_reimplement_context`,\n`code_writer_generate_plan`, `code_writer_generate_architecture`, and\n`create_doc` for **TDD** / **architecture** documents — ground their output in\nyour indexed codebase. They **BLOCK** until BOTH a VCS connection AND a\nsuccessful code index exist (`all_of`, with `code_index` in `missing`).\n\n### Council (BLOCK on a code index, mode-dependent)\n\n`request_council` in **technical** or **discovery** mode searches your indexed\ncodebase, so it **BLOCK**s on `code_index`. **Design**-mode brainstorming never\nqueries the index and is never gated.\n\n### Document generation that DEGRADEs (usable without codebase context)\n\nTools like `generate_prd_direct`, `generate_fsd_direct`,\n`code_writer_generate_fsd`, `generate_clarifying_questions_direct`,\n`generate_ticket_critique_direct`, `generate_ticket_review_direct`, and\n`create_doc` for **PRD** / **FSD** documents **DEGRADE** rather than block: they\nrun today from the ticket alone, and connecting VCS simply lets them ground their\noutput in your codebase. They always appear under \"Tools you can use now\", with a\nreduced-context caveat when the VCS connection is missing.\n\n### Never gated\n\nSetup and bootstrap tools (`ping`, `config_field`, `get_install_manifest`,\n`apply_install_manifest`, `get_my_role`, `persist_routing_credential`,\n`get_docs_dir`, and the bootstrap-invite exchange) are always available — they\nare how you configure everything else.\n",
5
5
  "docs/install/sfcc-integration.md": "# Installing the SFCC Integration (OCAPI)\n\nBridge's Salesforce B2C Commerce (SFCC) tools give an AI coding agent read access to\na sandbox's object model, custom object definitions, and site preferences — plus a\nsmall set of sandbox-only writes — through the **OCAPI Data API**. This guide covers\nsetting up the OCAPI client that those tools authenticate against.\n\n> **Sandbox / local development only.** This integration is intended for a **developer\n> sandbox**, and that restriction is **enforced in code**: before any SFCC tool runs,\n> Bridge validates the hostname your credentials actually resolve to — from `dw.json`\n> or `SFCC_*` — against the sandbox forms listed below. An unrecognized host is refused\n> with a `403` (`error.code: \"TARGET_NOT_SANDBOX\"`) before any request leaves your\n> machine. The check reads the resolved hostname, never the `instance` tool argument,\n> so omitting `instance` or passing `\"sandbox\"` cannot bypass it.\n>\n> Accepted sandbox hostname forms:\n>\n> - `<realm>-<nnn>.sandbox.<region>.dx.commercecloud.salesforce.com`\n> - `<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com`\n> - `<realm>-<nnn>.dx.commercecloud.salesforce.com`\n>\n> Anything else — a `production-`/`staging-`/`development-` prefixed host, or any\n> `*.demandware.net` host — is rejected.\n>\n> Still do not configure the grants below on an instance that holds real data.\n> Credentials stay local (in `dw.json` or `SFCC_*` env vars) and are never sent to\n> Bridge.\n\nFor the full per-tool list and what each SFCC tool depends on, see\n[MCP Tool Integration Dependencies](./mcp-tool-integrations.md). For the tool reference\nand the `BRIDGE_MCP_PROFILE` gating, see the SFCC section of the\n[package README](../../README.md).\n\n## Prerequisites\n\n- A running SFCC **developer sandbox** and its hostname\n (e.g. `zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com`).\n- An **Account Manager API client** — a `client-id` and `client-secret`. This is the\n OCAPI client the tools use to obtain an OAuth token. Create one in Account Manager\n (**API Client** → *Add API Client*) if you don't already have it, and note its\n `client_id`.\n- Business Manager access to the sandbox with permission to edit **Open Commerce API\n Settings**.\n\n## 1. Grant the OCAPI client access in Business Manager\n\nIn Business Manager for the sandbox:\n\n**Administration → Site Development → Open Commerce API Settings → Data API** tab.\n\nAdd the client entry below to the `clients` array of the Data API settings, then\n**Save**. It grants only the resource families and HTTP methods Bridge's SFCC tools\nactually call — not a global `/**` grant. `check_permissions` prints the same JSON on\na 401/403, split into the two blocks.\n\n**READ/SEARCH TOOL GRANTS** — required by the `sfcc` read tools. (`post` is OCAPI's\nconvention for its `*_search` endpoints, not a mutation.)\n\n```json\n{\n \"client_id\": \"<your-client-id-here>\",\n \"resources\": [\n { \"resource_id\": \"/system_object_definitions\", \"methods\": [\"get\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/system_object_definitions/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/site_preferences/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/custom_object_definitions/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" }\n ]\n}\n```\n\n**MUTATION GRANTS** — required **only if you enable `BRIDGE_MCP_PROFILE=sfcc-write`**,\nwhich registers the nine destructive write tools. These are shipped capabilities, not\nfuture work. No `delete` is granted, because no shipped write tool performs one; the\n`get` entries are needed for the If-Match ETag round trip that precedes each `PATCH`.\n\n```json\n{\n \"client_id\": \"<your-client-id-here>\",\n \"resources\": [\n { \"resource_id\": \"/system_object_definitions\", \"methods\": [\"get\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/system_object_definitions/**\", \"methods\": [\"get\", \"put\", \"patch\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/custom_object_definitions/**\", \"methods\": [\"get\", \"put\", \"patch\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/site_preferences/**\", \"methods\": [\"get\", \"patch\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" }\n ]\n}\n```\n\nNotes:\n\n- The `client_id` **must match** the Account Manager API client whose credentials you\n put in `dw.json` / `SFCC_*` below. Replace the value above with your own client id if\n it differs.\n- If the Data API settings are empty, wrap the entries in the standard settings\n envelope. Merge the resource lists from the block(s) above into one `resources`\n array — do not substitute a global `\"resource_id\": \"/**\"` grant:\n\n ```json\n {\n \"_v\": \"23.2\",\n \"clients\": [\n {\n \"client_id\": \"<your-client-id-here>\",\n \"resources\": [\n { \"resource_id\": \"/system_object_definitions\", \"methods\": [\"get\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/system_object_definitions/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/site_preferences/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" },\n { \"resource_id\": \"/custom_object_definitions/**\", \"methods\": [\"get\", \"post\"], \"read_attributes\": \"(**)\", \"write_attributes\": \"(**)\" }\n ]\n }\n ]\n }\n ```\n\n- `check_permissions` (below) prints a ready-to-paste grant JSON on a 401/403, so you can\n also let the tool tell you exactly what to add.\n\n## 2. Provide credentials locally\n\nCreate a `dw.json` in your project root (auto-added to git exclude — never commit it):\n\n```json\n{\n \"hostname\": \"zzzz-001.sandbox.us01.dx.commercecloud.salesforce.com\",\n \"client-id\": \"<your-client-id-here>\",\n \"client-secret\": \"<account-manager-client-secret>\"\n}\n```\n\nAccepted key spellings: `hostname`/`host`, `client-id`/`clientId`/`client_id`,\n`client-secret`/`clientSecret`/`client_secret`. Prefer a single config — a multi-entry\n`configs[]` array forces an explicit `instance` on every call. Alternatively, export\n`SFCC_HOSTNAME` / `SFCC_CLIENT_ID` / `SFCC_CLIENT_SECRET` in the MCP server environment.\n\n## 3. Set the repo `version` config field\n\nSet the repo's `version` config to your SFCC project type — one of\n`sfra | pwakit | sitegenesis | storefrontnext | hybrid`. The call-time gate reads this;\na non-SFCC value blocks every SFCC tool except `sfcc_setup_status`. Set it via your\nnormal config path, the `config_field` MCP tool (operation `update`, field `version`),\nor the `/teach-bridge` skill.\n\n## 4. Enable the SFCC tools\n\nThe two diagnostic tools (`sfcc_setup_status`, `check_permissions`) are always\nregistered. Everything else is gated, behind **two independent profile groups**:\n\n| Group | Registers |\n|---|---|\n| `sfcc` | the 8 OCAPI read tools + `sfcc_log_query` — read-only |\n| `sfcc-write` | the 9 destructive write tools |\n\nNeither implies the other. Add what you need to `BRIDGE_MCP_PROFILE` in the MCP server\n`env` block (it is comma-separated), then **restart the MCP client**:\n\n```json\n\"env\": { \"BRIDGE_MCP_PROFILE\": \"sfcc\" }\n```\n\nFor reads plus writes, use `\"sfcc,sfcc-write\"`. `full` expands to every group and is\ntherefore write-capable.\n\n> **Migration.** `sfcc` used to register the nine write tools too. It no longer does.\n> If you were relying on SFCC writes through `BRIDGE_MCP_PROFILE=sfcc`, change it to\n> `BRIDGE_MCP_PROFILE=sfcc,sfcc-write`. `full` users keep write access and need no\n> change.\n\n## 5. Verify\n\nAsk your agent to run:\n\n1. `sfcc_setup_status` — expect all prerequisite checks ✓ (Bridge API key, repo name,\n `version` config, `dw.json` presence/uniqueness, AM/OCAPI token acquisition).\n2. `check_permissions` — probes OCAPI via `GET /system_object_definitions`. A 200 (with\n the OCAPI version) confirms the grant. On 401/403 it prints the exact grant JSON to\n paste back in step 1.\n\nRestart the MCP client after any credential, grant, or env change — a running session\ndoes not pick them up.\n\n## Notes\n\n- **WebDAV logs are separate.** `sfcc_log_query` authenticates with a Business Manager\n username + a 40-character **WebDAV access key** over HTTP Basic auth — *not* the OCAPI\n OAuth token configured here. `sfcc_setup_status` reports OCAPI (step 5) and WebDAV\n (step 6) independently; one can be green while the other is not.\n- **Writes are sandbox-only.** The write tools (attribute/preference create/update) target\n a developer sandbox and echo a paste-ready grant JSON on a 403.\n",
6
- "docs/bridge-ticket-authoring.md": "# Bridge ticket-authoring posture\n\nBridge has several surfaces that can create a ticket. Without a shared posture\neach one behaves differently, and the most-used surface carries none of the\nmaintainer's preferences at all. This document is the deep reference behind the\nshort posture block that every one of those surfaces carries verbatim.\n\nThe block itself is short on purpose — it competes for attention inside prompts\nthat are already long. Everything that explains *why* lives here.\n\n## The canonical block\n\nThis file **is** the canonical source. The marker-delimited block below is\nduplicated byte-identically onto every carrier — every surface that decides\nticket shape holds these exact bytes, so no surface can quietly drift into its\nown house style.\n\nCarriers (all four hold the block verbatim):\n\n- `agents/src/jira-ticket-writer.md` — the writer itself, plus a compressed\n posture line in its `description:` frontmatter, which is the only coverage a\n bare-chat session gets with no file read and no `tools/list` cost.\n- `commands/src/explore-ticket.md` — Stage 9, the most-used authoring surface.\n- `mcp_server/instructions/decompose-epic-candidate.md` — the `idea-to-ticket`\n decomposition step.\n- `mcp_server/instructions/decompose-epic.md` — the `plan-epic` decomposition\n step.\n\nEdit the block here, copy it verbatim to each carrier, and let\n`tests/pytest/mcp_server/test_ticket_authoring_posture_assets.py` prove it\nlanded. That test permits **no** per-surface variation.\n\n<!-- BEGIN BRIDGE TICKET-AUTHORING POSTURE -->\n<!-- Canonical source: docs/bridge-ticket-authoring.md.\n This block is duplicated byte-identically onto every carrier. Never edit a\n copy: edit the canonical source and re-copy it verbatim. A cross-surface\n byte-equality test fails the build if any copy drifts by a single byte. -->\n\n## Ticket-authoring posture\n\nDeep reference: `docs/bridge-ticket-authoring.md`.\n\n**Draft through the writer.** Every ticket body — an epic parent, an epic child,\nand an ordinary sibling alike — is drafted by the `jira-ticket-writer` agent\nbefore `create_ticket` is called. Do not compose a ticket description inline.\n\n**Size the work.** Size each ticket by file-touch breadth and depth plus rough\nlines of code (LOC) changed:\n\n- `S = 1-2 files / <~80 LOC`\n- `M = ~3-8 files / ~80-400 LOC`\n- `L = ~8-15 files / ~400-900 LOC`\n- `XL = >15 files / >~900 LOC`\n\nTarget size priority: **L (target) -> XL (when the work does not fit in L) -> M\n(third choice) -> S (only when unavoidable)**. This applies equally to a\nstandalone ticket and to an epic child.\n\nAim each slice at L. When one will not fit, grow it to XL rather than splitting\nit — split only when the slice is genuinely two independent pieces of work,\nnever merely to land inside a band. Bridge's grooming and implementation process\nhandles a large vertical slice well and is overkill on small ones: every extra\nticket is another worktree, another PR, another rebase, and another chance for\ntwo workers to touch the same file. Reach for M because the work genuinely is\nthat size, not to avoid an XL.\n\nBeyond roughly 40 files or ~3000 LOC, split anyway. Past that point review\nturnaround and rebase cost dominate the run's budget, and a review that wedges\nholds the gate to its full retry ceiling before anyone notices.\n\n**Group at three.** Three or more implementable tickets is an epic: propose an\nepic parent plus an ordered child manifest, and resolve this surface's own\napproval gate before anything is created. One or two tickets are ordinary\nsiblings — no epic parent, no manifest. The threshold is exactly three.\n\n**Hand off once.** An epic handoff names exactly one conductor entry point,\n`drive-epic`, which selects the runnable path itself. Never present a choice\nbetween conductors.\n\n**Departure is closed-list only.** These three exceptions, and no others, permit\ndeparting from the rules above. Invoking one requires no announcement.\n\n- **E1 External-tracker mirroring** — a recorded upstream identifier exists and\n its granularity is contractual. Bypasses sizing and the epic threshold.\n- **E2 Discovery-only spike** — no committed production-code deliverable.\n Bypasses sizing only; does not bypass drafting through the writer.\n- **E3 Authorized incident containment** — tied to an active incident record,\n not to schedule pressure. Bypasses sizing and the epic threshold.\n\nThe list is closed. Anything outside it is an escalation to the operator, not a\njudgement call. Explicitly refused as grounds for departure: a single-file\ntrivial fix (that is `S` reached through the normal path, not an exception),\ngeneric time pressure, \"already well specified\", \"faster without the writer\",\ndeveloper discretion, minor refactor, unattended mode, context limits, and \"hard\nto decompose\" (XL is the normal overflow, so that is the ordinary path and not a\ndeparture). Writer unavailability escalates; it never silently authorizes inline\ndrafting.\n\n<!-- END BRIDGE TICKET-AUTHORING POSTURE -->\n\nThe rest of this document is the rationale the block is deliberately too short to\ncarry.\n\n\n## The four rules\n\n### 1. Draft through `jira-ticket-writer`\n\nEvery ticket body is drafted by the `jira-ticket-writer` agent before\n`create_ticket` is called — an epic parent, an epic child, and an ordinary\nsibling alike. Nothing composes a ticket description inline.\n\nThe writer is not a formatter. It runs a codebase-research pass first, so its\ntickets cite the files, functions, and extension points a change actually\ntouches. A description written inline skips that pass, and the difference shows\nup two steps later: plan generation and implementation both ground themselves in\nthe ticket body, so a body with no code references produces a plan with no code\nreferences.\n\n\"The ticket is already well specified\" is not a reason to skip the writer. A\nwell-specified *request* is the writer's input, not a substitute for its output.\n\n### 2. Size toward L\n\nSize each ticket by file-touch breadth and depth plus rough lines of code\nchanged:\n\n| Band | Files | LOC |\n| --- | --- | --- |\n| `S` | 1–2 | `<~80` |\n| `M` | ~3–8 | ~80–400 |\n| `L` | ~8–15 | ~400–900 |\n| `XL` | >15 | `>~900` |\n\nPriority: **L (target) → XL (when the work does not fit in L) → M (third choice)\n→ S (only when unavoidable)**.\n\nThe target is L because the Bridge implementation tooling works best on\nindependently implementable vertical slices. What matters as much as the target\nis the **direction you move when a slice misses it**: upward, not downward.\n\nA slice that will not fit in L becomes **one XL ticket**, not two L ones. Split\nonly when the slice is genuinely two independent pieces of work — never merely to\nland inside a band. Fragmenting a coherent slice to fit is the failure this\nladder exists to prevent: every extra ticket is another worktree, another PR,\nanother rebase, and another chance for two workers to touch the same file, and\nBridge's grooming process is overkill on small tickets. Fewer, larger slices\nspend less of the run's budget on coordination.\n\nThis applies equally to a standalone ticket and to an epic child. There is no\nsize ceiling on a child that a lone ticket does not also have.\n\n`M` is where you land when the work genuinely is three to eight files — not\nsomewhere to retreat to in order to avoid an XL. `S` is likewise not forbidden:\nit is simply what you reach when the work genuinely is one or two files. A\nsingle-file trivial fix is `S` arrived at through the normal path. It is not an\nexception to anything, and it does not license skipping the writer.\n\n**Beyond roughly 40 files or ~3000 LOC, split anyway.** XL is the preferred\noverflow, not an unbounded one. Past that point review turnaround and rebase cost\ndominate the run's budget, and a review that wedges holds the gate to its full\nretry ceiling before anyone notices. That is a real bound, not a preference — and\nit is high enough that reaching it means the work really is two things.\n\n### 3. Group at three\n\nThree or more implementable tickets is an epic. The surface proposes an epic\nparent plus an **ordered child manifest**, and resolves its own approval gate\nbefore anything is created.\n\nOne or two tickets are ordinary siblings: no epic parent, no manifest. The\nthreshold is exactly three — not \"several\", not \"a lot\".\n\nThe manifest carries, per child: the boundary of its scope, its size band,\n`depends_on` (hard prerequisites that must land first), `recommended_after` (soft\nsequencing preferences that are not blockers), and a one-line order rationale.\nHard prerequisites and soft sequencing stay strictly separate, because the\nrecommended implementation order is derived from them and conflating the two\nproduces a serialized order where a parallel one was available.\n\nApproval follows each surface's **existing** attended/unattended rule. Nothing\nhere introduces a new gate policy: `/explore-ticket` requires an explicit\naffirmative because creation is irreversible, and the recipe path gates on the\npipeline's own auto-approval variable. What is *not* conditional is the grouping\nitself — an unattended run still produces the epic; only the gate's behavior\nvaries.\n\nDecomposition happens **once**. The pass that decides the split freezes the\nmanifest; body rendering then fans out one writer invocation per entry against\nthat frozen manifest. A rendering invocation may not re-split, merge, reorder,\nrenumber, or rescope. Two independent decisions about the same split disagree,\nand the disagreement surfaces as children that overlap or contradict their\nparent.\n\n### 4. Hand off to exactly one conductor\n\nAn epic handoff names exactly one conductor entry point: `drive-epic`.\n\nBridge currently has two conductors — the v2 server-side engine and the LLM\nconductor pilot — and a standing rule that they must never operate on the same\nepic, because two transition authorities on one epic wedge it permanently. Asking\na model to pick correctly every time is not a control. `drive-epic` makes the\nchoice structural instead: it reads conductor readiness and routes to the one\npath the project can actually run, so no prompt names either underlying conductor\nand no prompt can present both.\n\nTwo conductors is a transitional state. When one is eliminated, `drive-epic` is\nthe only thing that changes — no prompt, bundled doc, command mirror, or posture\ntest moves.\n\n## The closed exception list\n\nExactly three exceptions permit departing from the rules above. Invoking one\nrequires **no announcement** — the departure is silent by design, because a\nmandatory announcement would be one more instruction to drift from, and the cost\nof silence was weighed and accepted.\n\n| Id | Exception | Objective trigger | Bypasses |\n| --- | --- | --- | --- |\n| **E1** | External-tracker mirroring | A recorded upstream identifier exists and its granularity is contractual | Sizing and the epic threshold |\n| **E2** | Discovery-only spike | No committed production-code deliverable | Sizing only — **not** drafting through the writer |\n| **E3** | Authorized incident containment | Tied to an active incident record, not to schedule pressure | Sizing and the epic threshold |\n\nEach trigger is objective: an identifier that exists, a deliverable that is\nabsent, an incident record that is open. None of them is a judgement about how\nthe work feels.\n\n### The list is closed\n\nAnything outside the three rows above is an **escalation to the operator**, not a\njudgement call. The following are explicitly refused as grounds for departure:\n\n- a single-file trivial fix — that is `S` reached through the normal path;\n- generic time pressure;\n- \"the request is already well specified\";\n- \"it would be faster without the writer\";\n- developer discretion;\n- \"it's just a minor refactor\";\n- running unattended;\n- context limits;\n- \"this is hard to decompose\" — XL is the normal overflow, so that is the\n ordinary path and not a departure.\n\n**Writer unavailability escalates.** It never silently authorizes inline\ndrafting. A surface that cannot reach `jira-ticket-writer` stops and says so.\n\n## Accepted trade-off: silent departure is unobservable\n\nObservability was deliberately dropped when this posture was ratified. A model\nmay invoke E1, E2, or E3 without recording that it did, so posture drift is only\ndetectable through ticket quality — not through a log, a counter, or a report.\n\nThis is known and accepted. The alternative was another mandatory instruction on\nevery surface, and an instruction that is skipped silently is worse than one that\ndoes not exist: it reads as coverage while providing none.\n\n## Why duplication, not a shared include\n\nCommands, agents, instructions, and docs have four separate build paths in this\nrepository and no shared compiler. Introducing a generated include step to share\none block would mean a fifth build path, a placeholder that can go unresolved,\nand a failure mode where a carrier ships with the placeholder text still in it.\n\nMarker-delimited duplication plus one byte-equality test is the right mechanism\nat this scale. The test reads the canonical sources directly — never the\ngenerated command mirrors, whose byte-identity the command tests already cover —\nand permits **no** per-surface variation. Any drift, down to a single byte, fails.\n\n## A fresh install inherits this\n\nNo configuration step, no server call. The posture reaches a new project through\nthe packaged bundles that `--init` scaffolds:\n\n- `COMMANDS` (`mcp_server/src/commands.generated.ts`) — carries\n `commands/src/explore-ticket.md`;\n- `AGENTS` (`mcp_server/src/agents.generated.ts`) — carries\n `agents/src/jira-ticket-writer.md`, including the compressed posture line in\n its `description:` frontmatter;\n- `INSTRUCTIONS` (`mcp_server/src/pipelines.generated.ts`) — carries the\n canonical source and both decomposition instructions;\n- `DOCS` (`mcp_server/src/docs.generated.ts`) — carries this document.\n\nThe compressed frontmatter line matters more than its size suggests: agent\ndescriptions land in every session's system prompt with no file read and no\n`tools/list` cost, so it is the entire bare-chat coverage story.\n\n## Worked examples\n\n**One ticket.** \"Add a `--json` flag to `doctor`.\" Two files and a test, ~90 LOC.\nThat is `M`. One ticket, drafted by the writer, no epic, no manifest, no\nconductor handoff.\n\n**Two tickets.** \"Add rate limiting to the LLM client, and surface the limit in\nthe config UI.\" Backend and frontend are independently implementable and land\nseparately: two ordinary siblings. Still no epic — the threshold is three.\n\n**Four tickets → an epic.** \"Make local ticket mode a first-class system.\"\nDecomposition freezes a parent plus four children, each `L`, with `depends_on`\nnaming the one child that must land first. The full manifest goes to the approval\ngate; on approval, four writer invocations render four bodies against their\nfrozen entries; creation follows `upload-epic-hierarchy.md`; the handoff names\n`drive-epic` and nothing else.\n\n**A child that outgrows `L`.** A proposed child comes out at 19 files. It ships\nas one `XL` child. Do not split it into two `L` children to make it fit — the\nslice is one coherent piece of work, and halving it buys a second worktree, a\nsecond PR, and a rebase between them in exchange for nothing. Split only if the\n19 files really are two independent deliverables.\n\n**Past the ceiling.** A proposed ticket comes out at 60 files and ~5000 LOC.\nThat is over the bound, so it splits — but into the largest coherent pieces\navailable, not into a swarm. Two `XL` tickets is the right answer here; six `M`\nones is not.\n"
6
+ "docs/bridge-ticket-authoring.md": "# Bridge ticket-authoring posture\n\nBridge has several surfaces that can create a ticket. Without a shared posture\neach one behaves differently, and the most-used surface carries none of the\nmaintainer's preferences at all. This document is the deep reference behind the\nshort posture block that every one of those surfaces carries verbatim.\n\nThe block itself is short on purpose — it competes for attention inside prompts\nthat are already long. Everything that explains *why* lives here.\n\n## The canonical block\n\nThis file **is** the canonical source. The marker-delimited block below is\nduplicated byte-identically onto every carrier — every surface that decides\nticket shape holds these exact bytes, so no surface can quietly drift into its\nown house style.\n\nCarriers (all four hold the block verbatim):\n\n- `agents/src/jira-ticket-writer.md` — the writer itself, plus a compressed\n posture line in its `description:` frontmatter, which is the only coverage a\n bare-chat session gets with no file read and no `tools/list` cost.\n- `commands/src/explore-ticket.md` — Stage 9, the most-used authoring surface.\n- `mcp_server/instructions/decompose-epic-candidate.md` — the `idea-to-ticket`\n decomposition step.\n- `mcp_server/instructions/decompose-epic.md` — the `plan-epic` decomposition\n step.\n\nEdit the block here, copy it verbatim to each carrier, and let\n`tests/pytest/mcp_server/test_ticket_authoring_posture_assets.py` prove it\nlanded. That test permits **no** per-surface variation.\n\n<!-- BEGIN BRIDGE TICKET-AUTHORING POSTURE -->\n<!-- Canonical source: docs/bridge-ticket-authoring.md.\n This block is duplicated byte-identically onto every carrier. Never edit a\n copy: edit the canonical source and re-copy it verbatim. A cross-surface\n byte-equality test fails the build if any copy drifts by a single byte. -->\n\n## Ticket-authoring posture\n\nDeep reference: `docs/bridge-ticket-authoring.md`.\n\n**Draft through the writer.** Every ticket body — an epic parent, an epic child,\nand an ordinary sibling alike — is drafted by the `jira-ticket-writer` agent\nbefore `create_ticket` is called. Do not compose a ticket description inline.\n\n**Size the work.** Size each ticket by file-touch breadth and depth plus rough\nlines of code (LOC) changed:\n\n- `S = 1-2 files / <~80 LOC`\n- `M = ~3-8 files / ~80-400 LOC`\n- `L = ~8-15 files / ~400-900 LOC`\n- `XL = >15 files / >~900 LOC`\n\nTarget size priority: **L (target) -> XL (when the work does not fit in L) -> M\n(third choice) -> S (only when unavoidable)**. This applies equally to a\nstandalone ticket and to an epic child.\n\nAim each slice at L. When one will not fit, grow it to XL rather than splitting\nit — split only when the slice is genuinely two independent pieces of work,\nnever merely to land inside a band. Bridge's grooming and implementation process\nhandles a large vertical slice well and is overkill on small ones: every extra\nticket is another worktree, another PR, another rebase, and another chance for\ntwo workers to touch the same file. Reach for M because the work genuinely is\nthat size, not to avoid an XL.\n\nBeyond roughly 40 files or ~3000 LOC, split anyway. Past that point review\nturnaround and rebase cost dominate the run's budget, and a review that wedges\nholds the gate to its full retry ceiling before anyone notices.\n\n**Group at three.** Three or more implementable tickets is an epic: propose an\nepic parent plus an ordered child manifest, and resolve this surface's own\napproval gate before anything is created. One or two tickets are ordinary\nsiblings — no epic parent, no manifest. The threshold is exactly three.\n\n**Hand off once.** An epic handoff names exactly one conductor entry point,\n`drive-epic`, which selects the runnable path itself. Never present a choice\nbetween conductors.\n\n**Departure is closed-list only.** These three exceptions, and no others, permit\ndeparting from the rules above. Invoking one requires no announcement.\n\n- **E1 External-tracker mirroring** — a recorded upstream identifier exists and\n its granularity is contractual. Bypasses sizing and the epic threshold.\n- **E2 Discovery-only spike** — no committed production-code deliverable.\n Bypasses sizing only; does not bypass drafting through the writer.\n- **E3 Authorized incident containment** — tied to an active incident record,\n not to schedule pressure. Bypasses sizing and the epic threshold.\n\nThe list is closed. Anything outside it is an escalation to the operator, not a\njudgement call. Explicitly refused as grounds for departure: a single-file\ntrivial fix (that is `S` reached through the normal path, not an exception),\ngeneric time pressure, \"already well specified\", \"faster without the writer\",\ndeveloper discretion, minor refactor, unattended mode, context limits, and \"hard\nto decompose\" (XL is the normal overflow, so that is the ordinary path and not a\ndeparture). Writer unavailability escalates; it never silently authorizes inline\ndrafting.\n\n<!-- END BRIDGE TICKET-AUTHORING POSTURE -->\n\nThe rest of this document is the rationale the block is deliberately too short to\ncarry.\n\n## Decision: Jira ticket authoring ships through the Jira Ticket Writer (BAPI-900)\n\nBridge ships two very different kinds of ticket-authoring surface, and customers\nneed to know which one they actually have.\n\n**Shipped customer surface.** A customer project gets the `jira-ticket-writer`\nagent — the same writer this posture requires every ticket body to go through —\nplus the agent-directed capability to revise an existing ticket's description.\nAsk your agent to draft a ticket with the Jira Ticket Writer, and ask your agent\nto update an existing ticket's description when it needs revising. Both reach a\ncustomer project because they are packaged: the writer through `AGENTS`\n(`mcp_server/src/agents.generated.ts`) and the description-update path through\nthe registered `update_ticket_description` / `request_ticket_update` MCP tools.\n\n**Repository-local workflows.** `.claude/commands/write-ticket.md` and\n`.claude/commands/update-ticket.md` are bridge-api's own repository-maintenance\ncommands. They are **not** scaffolded into a customer project by `--init`, have\nno `commands/src/` source, and have no generated Cursor or `mcp_server/`\nmirror — deliberately, not by omission. A customer asking their agent to run\nthe write-ticket or update-ticket slash command will not find either one,\nbecause neither ships.\n\n**Why (Option B, not a promotion to shipped status).** The Jira Ticket Writer is\nalready the packaged drafting surface this posture mandates, so shipping\n`write-ticket.md` as a second drafting entry point would duplicate it. More\nimportantly, `write-ticket.md` is today an autonomous, single-ticket, no-halt\npipeline (\"No human confirmation gates — run end-to-end\") with no decomposition\nstep and no approval gate — it cannot honor the \"group at three\" epic rule or\nthe epic approval gate this posture requires, because it was never built to\npropose an epic at all. Promoting it to a shipped surface without that redesign\nwould ship a customer-facing command that silently violates this file's own\nposture. Until that redesign happens, `write-ticket.md` and `update-ticket.md`\nstay repository-local, and every packaged surface directs customers to the Jira\nTicket Writer and to agent-directed description updates instead.\n\n\n## The four rules\n\n### 1. Draft through `jira-ticket-writer`\n\nEvery ticket body is drafted by the `jira-ticket-writer` agent before\n`create_ticket` is called — an epic parent, an epic child, and an ordinary\nsibling alike. Nothing composes a ticket description inline.\n\nThe writer is not a formatter. It runs a codebase-research pass first, so its\ntickets cite the files, functions, and extension points a change actually\ntouches. A description written inline skips that pass, and the difference shows\nup two steps later: plan generation and implementation both ground themselves in\nthe ticket body, so a body with no code references produces a plan with no code\nreferences.\n\n\"The ticket is already well specified\" is not a reason to skip the writer. A\nwell-specified *request* is the writer's input, not a substitute for its output.\n\n### 2. Size toward L\n\nSize each ticket by file-touch breadth and depth plus rough lines of code\nchanged:\n\n| Band | Files | LOC |\n| --- | --- | --- |\n| `S` | 1–2 | `<~80` |\n| `M` | ~3–8 | ~80–400 |\n| `L` | ~8–15 | ~400–900 |\n| `XL` | >15 | `>~900` |\n\nPriority: **L (target) → XL (when the work does not fit in L) → M (third choice)\n→ S (only when unavoidable)**.\n\nThe target is L because the Bridge implementation tooling works best on\nindependently implementable vertical slices. What matters as much as the target\nis the **direction you move when a slice misses it**: upward, not downward.\n\nA slice that will not fit in L becomes **one XL ticket**, not two L ones. Split\nonly when the slice is genuinely two independent pieces of work — never merely to\nland inside a band. Fragmenting a coherent slice to fit is the failure this\nladder exists to prevent: every extra ticket is another worktree, another PR,\nanother rebase, and another chance for two workers to touch the same file, and\nBridge's grooming process is overkill on small tickets. Fewer, larger slices\nspend less of the run's budget on coordination.\n\nThis applies equally to a standalone ticket and to an epic child. There is no\nsize ceiling on a child that a lone ticket does not also have.\n\n`M` is where you land when the work genuinely is three to eight files — not\nsomewhere to retreat to in order to avoid an XL. `S` is likewise not forbidden:\nit is simply what you reach when the work genuinely is one or two files. A\nsingle-file trivial fix is `S` arrived at through the normal path. It is not an\nexception to anything, and it does not license skipping the writer.\n\n**Beyond roughly 40 files or ~3000 LOC, split anyway.** XL is the preferred\noverflow, not an unbounded one. Past that point review turnaround and rebase cost\ndominate the run's budget, and a review that wedges holds the gate to its full\nretry ceiling before anyone notices. That is a real bound, not a preference — and\nit is high enough that reaching it means the work really is two things.\n\n### 3. Group at three\n\nThree or more implementable tickets is an epic. The surface proposes an epic\nparent plus an **ordered child manifest**, and resolves its own approval gate\nbefore anything is created.\n\nOne or two tickets are ordinary siblings: no epic parent, no manifest. The\nthreshold is exactly three — not \"several\", not \"a lot\".\n\nThe manifest carries, per child: the boundary of its scope, its size band,\n`depends_on` (hard prerequisites that must land first), `recommended_after` (soft\nsequencing preferences that are not blockers), and a one-line order rationale.\nHard prerequisites and soft sequencing stay strictly separate, because the\nrecommended implementation order is derived from them and conflating the two\nproduces a serialized order where a parallel one was available.\n\nApproval follows each surface's **existing** attended/unattended rule. Nothing\nhere introduces a new gate policy: `/explore-ticket` requires an explicit\naffirmative because creation is irreversible, and the recipe path gates on the\npipeline's own auto-approval variable. What is *not* conditional is the grouping\nitself — an unattended run still produces the epic; only the gate's behavior\nvaries.\n\nDecomposition happens **once**. The pass that decides the split freezes the\nmanifest; body rendering then fans out one writer invocation per entry against\nthat frozen manifest. A rendering invocation may not re-split, merge, reorder,\nrenumber, or rescope. Two independent decisions about the same split disagree,\nand the disagreement surfaces as children that overlap or contradict their\nparent.\n\n### 4. Hand off to exactly one conductor\n\nAn epic handoff names exactly one conductor entry point: `drive-epic`.\n\nBridge currently has two conductors — the v2 server-side engine and the LLM\nconductor pilot — and a standing rule that they must never operate on the same\nepic, because two transition authorities on one epic wedge it permanently. Asking\na model to pick correctly every time is not a control. `drive-epic` makes the\nchoice structural instead: it reads conductor readiness and routes to the one\npath the project can actually run, so no prompt names either underlying conductor\nand no prompt can present both.\n\nTwo conductors is a transitional state. When one is eliminated, `drive-epic` is\nthe only thing that changes — no prompt, bundled doc, command mirror, or posture\ntest moves.\n\n## The closed exception list\n\nExactly three exceptions permit departing from the rules above. Invoking one\nrequires **no announcement** — the departure is silent by design, because a\nmandatory announcement would be one more instruction to drift from, and the cost\nof silence was weighed and accepted.\n\n| Id | Exception | Objective trigger | Bypasses |\n| --- | --- | --- | --- |\n| **E1** | External-tracker mirroring | A recorded upstream identifier exists and its granularity is contractual | Sizing and the epic threshold |\n| **E2** | Discovery-only spike | No committed production-code deliverable | Sizing only — **not** drafting through the writer |\n| **E3** | Authorized incident containment | Tied to an active incident record, not to schedule pressure | Sizing and the epic threshold |\n\nEach trigger is objective: an identifier that exists, a deliverable that is\nabsent, an incident record that is open. None of them is a judgement about how\nthe work feels.\n\n### The list is closed\n\nAnything outside the three rows above is an **escalation to the operator**, not a\njudgement call. The following are explicitly refused as grounds for departure:\n\n- a single-file trivial fix — that is `S` reached through the normal path;\n- generic time pressure;\n- \"the request is already well specified\";\n- \"it would be faster without the writer\";\n- developer discretion;\n- \"it's just a minor refactor\";\n- running unattended;\n- context limits;\n- \"this is hard to decompose\" — XL is the normal overflow, so that is the\n ordinary path and not a departure.\n\n**Writer unavailability escalates.** It never silently authorizes inline\ndrafting. A surface that cannot reach `jira-ticket-writer` stops and says so.\n\n## Accepted trade-off: silent departure is unobservable\n\nObservability was deliberately dropped when this posture was ratified. A model\nmay invoke E1, E2, or E3 without recording that it did, so posture drift is only\ndetectable through ticket quality — not through a log, a counter, or a report.\n\nThis is known and accepted. The alternative was another mandatory instruction on\nevery surface, and an instruction that is skipped silently is worse than one that\ndoes not exist: it reads as coverage while providing none.\n\n## Why duplication, not a shared include\n\nCommands, agents, instructions, and docs have four separate build paths in this\nrepository and no shared compiler. Introducing a generated include step to share\none block would mean a fifth build path, a placeholder that can go unresolved,\nand a failure mode where a carrier ships with the placeholder text still in it.\n\nMarker-delimited duplication plus one byte-equality test is the right mechanism\nat this scale. The test reads the canonical sources directly — never the\ngenerated command mirrors, whose byte-identity the command tests already cover —\nand permits **no** per-surface variation. Any drift, down to a single byte, fails.\n\n## A fresh install inherits this\n\nNo configuration step, no server call. The posture reaches a new project through\nthe packaged bundles that `--init` scaffolds:\n\n- `COMMANDS` (`mcp_server/src/commands.generated.ts`) — carries\n `commands/src/explore-ticket.md`;\n- `AGENTS` (`mcp_server/src/agents.generated.ts`) — carries\n `agents/src/jira-ticket-writer.md`, including the compressed posture line in\n its `description:` frontmatter;\n- `INSTRUCTIONS` (`mcp_server/src/pipelines.generated.ts`) — carries the\n canonical source and both decomposition instructions;\n- `DOCS` (`mcp_server/src/docs.generated.ts`) — carries this document.\n\nThe compressed frontmatter line matters more than its size suggests: agent\ndescriptions land in every session's system prompt with no file read and no\n`tools/list` cost, so it is the entire bare-chat coverage story.\n\n## Worked examples\n\n**One ticket.** \"Add a `--json` flag to `doctor`.\" Two files and a test, ~90 LOC.\nThat is `M`. One ticket, drafted by the writer, no epic, no manifest, no\nconductor handoff.\n\n**Two tickets.** \"Add rate limiting to the LLM client, and surface the limit in\nthe config UI.\" Backend and frontend are independently implementable and land\nseparately: two ordinary siblings. Still no epic — the threshold is three.\n\n**Four tickets → an epic.** \"Make local ticket mode a first-class system.\"\nDecomposition freezes a parent plus four children, each `L`, with `depends_on`\nnaming the one child that must land first. The full manifest goes to the approval\ngate; on approval, four writer invocations render four bodies against their\nfrozen entries; creation follows `upload-epic-hierarchy.md`; the handoff names\n`drive-epic` and nothing else.\n\n**A child that outgrows `L`.** A proposed child comes out at 19 files. It ships\nas one `XL` child. Do not split it into two `L` children to make it fit — the\nslice is one coherent piece of work, and halving it buys a second worktree, a\nsecond PR, and a rebase between them in exchange for nothing. Split only if the\n19 files really are two independent deliverables.\n\n**Past the ceiling.** A proposed ticket comes out at 60 files and ~5000 LOC.\nThat is over the bound, so it splits — but into the largest coherent pieces\navailable, not into a swarm. Two `XL` tickets is the right answer here; six `M`\nones is not.\n"
7
7
  };