@bridge_gpt/mcp-server 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +554 -66
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +17 -9
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, deterministic observed-state rebuild + ready-set computation for the
|
|
3
|
+
* Epic Supervisor (BAPI-408).
|
|
4
|
+
*
|
|
5
|
+
* This module has NO I/O, NO timers, and NO LLM calls. Every time-dependent
|
|
6
|
+
* function takes an explicit `now` (epoch ms) so tests are wall-clock
|
|
7
|
+
* independent. Truth precedence: raw local ledger events override non-terminal
|
|
8
|
+
* Postgres states.
|
|
9
|
+
*/
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Status sets (module-private)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const NOT_STARTED_STATUS = "planned";
|
|
14
|
+
const DONE_STATUSES = new Set(["done"]);
|
|
15
|
+
const NON_TERMINAL_STATUSES = new Set([
|
|
16
|
+
"planned",
|
|
17
|
+
"ready",
|
|
18
|
+
"dispatched",
|
|
19
|
+
"running",
|
|
20
|
+
"blocked",
|
|
21
|
+
]);
|
|
22
|
+
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
23
|
+
"gate.met",
|
|
24
|
+
"merge.succeeded",
|
|
25
|
+
"ci.failed",
|
|
26
|
+
"run.stopped",
|
|
27
|
+
]);
|
|
28
|
+
function isNonTerminal(status) {
|
|
29
|
+
return NON_TERMINAL_STATUSES.has(status);
|
|
30
|
+
}
|
|
31
|
+
function signalToNextStatus(signalType) {
|
|
32
|
+
if (signalType === "ci.failed")
|
|
33
|
+
return "blocked";
|
|
34
|
+
return "done";
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// computeReadySet
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Pure deterministic ready-set computation. Returns ticket keys that:
|
|
41
|
+
* 1. Have status "planned" (not yet started), AND
|
|
42
|
+
* 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
|
|
43
|
+
*
|
|
44
|
+
* Never calls an LLM or performs I/O. Goal 8 invariant.
|
|
45
|
+
*/
|
|
46
|
+
export function computeReadySet(plan, ticketStatuses) {
|
|
47
|
+
const ready = [];
|
|
48
|
+
for (const ticket of plan.tickets) {
|
|
49
|
+
const currentStatus = ticketStatuses.get(ticket.ticket_key) ?? "planned";
|
|
50
|
+
if (currentStatus !== NOT_STARTED_STATUS)
|
|
51
|
+
continue;
|
|
52
|
+
const allDepsResolved = ticket.depends_on.every((dep) => DONE_STATUSES.has(ticketStatuses.get(dep) ?? "planned"));
|
|
53
|
+
if (allDepsResolved)
|
|
54
|
+
ready.push(ticket.ticket_key);
|
|
55
|
+
}
|
|
56
|
+
return ready;
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// rebuildObservedState
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Rebuild the merged observed state from the durable Postgres desired state
|
|
63
|
+
* and raw local ledger events. Local sticky terminal signals override
|
|
64
|
+
* non-terminal Postgres states. Pure: no I/O, no side-effects, explicit `now`.
|
|
65
|
+
*/
|
|
66
|
+
export function rebuildObservedState(postgresState, events, _now) {
|
|
67
|
+
const { epic_run, ticket_statuses, dispatches } = postgresState;
|
|
68
|
+
// Build run_id → ticket_key lookup from dispatch records
|
|
69
|
+
const runIdToTicketKey = new Map();
|
|
70
|
+
for (const dispatch of dispatches) {
|
|
71
|
+
if (dispatch.run_id) {
|
|
72
|
+
runIdToTicketKey.set(dispatch.run_id, dispatch.ticket_key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Populate base maps from Postgres
|
|
76
|
+
const ticketStatusMap = new Map();
|
|
77
|
+
const ticketRowVersionMap = new Map();
|
|
78
|
+
for (const ts of ticket_statuses) {
|
|
79
|
+
ticketStatusMap.set(ts.ticket_key, ts.status);
|
|
80
|
+
ticketRowVersionMap.set(ts.ticket_key, ts.row_version);
|
|
81
|
+
}
|
|
82
|
+
const unfoldedSignals = [];
|
|
83
|
+
const pendingMergeEvents = [];
|
|
84
|
+
// Track which tickets already have a folded signal (one override per ticket)
|
|
85
|
+
const foldedTicketKeys = new Set();
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
88
|
+
continue;
|
|
89
|
+
// Map event → ticket via run_id
|
|
90
|
+
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
91
|
+
const ticketKey = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
92
|
+
if (!ticketKey)
|
|
93
|
+
continue;
|
|
94
|
+
if (event.type === "gate.met") {
|
|
95
|
+
pendingMergeEvents.push(event);
|
|
96
|
+
}
|
|
97
|
+
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
98
|
+
if (!isNonTerminal(postgresStatus))
|
|
99
|
+
continue;
|
|
100
|
+
if (foldedTicketKeys.has(ticketKey))
|
|
101
|
+
continue;
|
|
102
|
+
const signalType = event.type;
|
|
103
|
+
const nextStatus = signalToNextStatus(signalType);
|
|
104
|
+
const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
|
|
105
|
+
unfoldedSignals.push({
|
|
106
|
+
ticket_key: ticketKey,
|
|
107
|
+
postgres_row_version: rowVersion,
|
|
108
|
+
next_status: nextStatus,
|
|
109
|
+
signal_type: signalType,
|
|
110
|
+
event,
|
|
111
|
+
});
|
|
112
|
+
// Apply override locally so the effective status map reflects the change
|
|
113
|
+
ticketStatusMap.set(ticketKey, nextStatus);
|
|
114
|
+
foldedTicketKeys.add(ticketKey);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
epic_key: epic_run.epic_key,
|
|
118
|
+
epic_status: epic_run.status,
|
|
119
|
+
plan_version: epic_run.current_plan_version,
|
|
120
|
+
ticket_statuses: ticketStatusMap,
|
|
121
|
+
ticket_row_versions: ticketRowVersionMap,
|
|
122
|
+
unfolded_terminal_signals: unfoldedSignals,
|
|
123
|
+
pending_merge_events: pendingMergeEvents,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor error types and the secret-safe error-envelope mapper.
|
|
3
|
+
*
|
|
4
|
+
* Every error surfaced to an MCP caller or the CLI passes through
|
|
5
|
+
* {@link toConductorErrorEnvelope}, which maps an arbitrary thrown value to a
|
|
6
|
+
* small structured envelope with an HTTP-style status. The returned `message`
|
|
7
|
+
* is sanitized: it NEVER echoes raw event payloads, secret material, or stack
|
|
8
|
+
* traces. Only validation errors (which the conductor constructs itself with
|
|
9
|
+
* safe text) carry their message through; everything else collapses to a
|
|
10
|
+
* generic message.
|
|
11
|
+
*/
|
|
12
|
+
import { redactSecretString } from "./redaction.js";
|
|
13
|
+
/** Raised when caller input violates a conductor validation rule. */
|
|
14
|
+
export class ConductorValidationError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ConductorValidationError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Raised when the underlying SQLite store cannot complete an operation. */
|
|
21
|
+
export class ConductorStoreError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ConductorStoreError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Heuristically detect SQLite busy/locked failures so they can be surfaced as a
|
|
29
|
+
* retryable 503 rather than an opaque 500. Matches the `better-sqlite3` error
|
|
30
|
+
* `code` field (`SQLITE_BUSY` / `SQLITE_LOCKED`) and the common message text.
|
|
31
|
+
*/
|
|
32
|
+
function isSqliteBusyError(error) {
|
|
33
|
+
if (!error || typeof error !== "object")
|
|
34
|
+
return false;
|
|
35
|
+
const code = error.code;
|
|
36
|
+
// Match the extended result codes too (e.g. SQLITE_BUSY_SNAPSHOT,
|
|
37
|
+
// SQLITE_BUSY_RECOVERY, SQLITE_LOCKED_SHAREDCACHE), which better-sqlite3 surfaces
|
|
38
|
+
// on `code`. A deferred read-then-write transaction that loses a WAL snapshot
|
|
39
|
+
// race throws SQLITE_BUSY_SNAPSHOT, and that should map to a retryable 503, not
|
|
40
|
+
// an opaque 500.
|
|
41
|
+
if (typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"))) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const message = error.message;
|
|
45
|
+
if (typeof message === "string") {
|
|
46
|
+
const lowered = message.toLowerCase();
|
|
47
|
+
if (lowered.includes("database is locked") ||
|
|
48
|
+
lowered.includes("database is busy") ||
|
|
49
|
+
lowered.includes("sqlite_busy") ||
|
|
50
|
+
lowered.includes("sqlite_locked")) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Map any thrown value to a secret-safe {@link ConductorErrorEnvelope}.
|
|
58
|
+
*
|
|
59
|
+
* - {@link ConductorValidationError} -> `{ VALIDATION_ERROR, 400 }` (message
|
|
60
|
+
* carried through, since the conductor authors it with safe text).
|
|
61
|
+
* - Distinguishable SQLite busy/locked failures -> `{ STORE_BUSY, 503 }` with
|
|
62
|
+
* a generic message.
|
|
63
|
+
* - Everything else -> `{ INTERNAL_ERROR, 500 }` with a fixed generic message;
|
|
64
|
+
* the raw error text, stack, and any secret material are discarded.
|
|
65
|
+
*/
|
|
66
|
+
export function toConductorErrorEnvelope(error) {
|
|
67
|
+
if (error instanceof ConductorValidationError) {
|
|
68
|
+
// Validation messages are conductor-authored and name the offending field,
|
|
69
|
+
// never its value — but redact defensively so an adversarial/secret-bearing
|
|
70
|
+
// message can never round-trip back to the caller.
|
|
71
|
+
return { error: "VALIDATION_ERROR", status: 400, message: redactSecretString(error.message) };
|
|
72
|
+
}
|
|
73
|
+
if (isSqliteBusyError(error)) {
|
|
74
|
+
return {
|
|
75
|
+
error: "STORE_BUSY",
|
|
76
|
+
status: 503,
|
|
77
|
+
message: "Conductor ledger is busy; retry shortly.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
error: "INTERNAL_ERROR",
|
|
82
|
+
status: 500,
|
|
83
|
+
message: "Conductor ledger operation failed.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, tool-agnostic contracts and boundary validators for the conductor
|
|
3
|
+
* git / PR / CI producer (BAPI-395).
|
|
4
|
+
*
|
|
5
|
+
* Everything here is provider-neutral: there are no Claude-specific fields and no
|
|
6
|
+
* GitHub/Bitbucket-native PR/CI fields outside an explicit `raw` channel. The git
|
|
7
|
+
* hooks, the PR/CI observer, and the done-gate evaluator all derive their accepted
|
|
8
|
+
* vocabulary, normalization, and stable hashing from this single module so the
|
|
9
|
+
* binding identity (`repo + pr_number + head_sha`) and gate config hashes can
|
|
10
|
+
* never drift between producers.
|
|
11
|
+
*
|
|
12
|
+
* This module is pure: it performs no I/O, spawns no subprocess, and never throws
|
|
13
|
+
* for invalid input — every validator returns `null` for unusable values so the
|
|
14
|
+
* whole producer surface fails closed rather than emitting half-formed events.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Identity constants
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Per-repo config field that carries the v1 done-gate definition. */
|
|
21
|
+
export const DONE_GATE_CONFIG_FIELD = "conductor_done_gate";
|
|
22
|
+
/** `producer` identity for PR/CI/gate events emitted by the observer. */
|
|
23
|
+
export const GIT_CI_PRODUCER = "git-pr-ci-producer";
|
|
24
|
+
/** `producer` identity for events emitted by the local git hooks. */
|
|
25
|
+
export const GIT_HOOK_PRODUCER = "git-hook";
|
|
26
|
+
/** The single v1 done-gate condition type. */
|
|
27
|
+
export const REQUIRED_CI_CHECKS_GREEN = "required_ci_checks_green";
|
|
28
|
+
/** Default gate name surfaced in `gate.met` event data. */
|
|
29
|
+
export const DEFAULT_GATE_NAME = "done";
|
|
30
|
+
/** Matches ASCII control characters (C0 range plus DEL). */
|
|
31
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
32
|
+
/** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
|
|
33
|
+
const SHA_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Boundary validators (fail closed, never throw)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Trim and validate a repository name. Accepts any non-empty string free of
|
|
39
|
+
* control characters (slashes and dots are valid — `org/repo`, `team.repo`).
|
|
40
|
+
* Returns the trimmed value, or `null` for empty/whitespace/control-char/
|
|
41
|
+
* non-string input.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRepoName(value) {
|
|
44
|
+
if (typeof value !== "string")
|
|
45
|
+
return null;
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
if (trimmed.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
if (CONTROL_CHAR_RE.test(trimmed))
|
|
50
|
+
return null;
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Validate a git object id. Outer whitespace is trimmed, then the value must be
|
|
55
|
+
* exactly 40 or 64 hex characters (case-insensitive). Returns the lowercase,
|
|
56
|
+
* normalized SHA or `null`. Non-hex content, wrong lengths, and non-strings are
|
|
57
|
+
* rejected — never silently coerced into a usable value.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeSha(value) {
|
|
60
|
+
if (typeof value !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
const lowered = value.trim().toLowerCase();
|
|
63
|
+
if (!SHA_RE.test(lowered))
|
|
64
|
+
return null;
|
|
65
|
+
return lowered;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate a PR number. Accepts only an actual positive safe integer; rejects 0,
|
|
69
|
+
* negatives, non-integers, values beyond `Number.MAX_SAFE_INTEGER`, and string
|
|
70
|
+
* coercions (`"123"`). Returns the number or `null`.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizePrNumber(value) {
|
|
73
|
+
if (typeof value !== "number")
|
|
74
|
+
return null;
|
|
75
|
+
if (!Number.isSafeInteger(value))
|
|
76
|
+
return null;
|
|
77
|
+
if (value <= 0)
|
|
78
|
+
return null;
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Trim and validate a CI check name. Provider-neutral: spaces, slashes, and
|
|
83
|
+
* colons are preserved (`"test / python"`, `"CI: unit-tests"`). Empty,
|
|
84
|
+
* whitespace-only, control-char-bearing, and non-string names return `null`.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeCheckName(value) {
|
|
87
|
+
if (typeof value !== "string")
|
|
88
|
+
return null;
|
|
89
|
+
const trimmed = value.trim();
|
|
90
|
+
if (trimmed.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (CONTROL_CHAR_RE.test(trimmed))
|
|
93
|
+
return null;
|
|
94
|
+
return trimmed;
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Stable canonical hashing
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Produce a canonical JSON representation with object keys sorted recursively.
|
|
101
|
+
* Array order is preserved (it is semantically significant); object key order is
|
|
102
|
+
* not (it is not). Used as the input to {@link stableJsonHash}.
|
|
103
|
+
*/
|
|
104
|
+
function canonicalize(value) {
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return value.map((item) => canonicalize(item));
|
|
107
|
+
}
|
|
108
|
+
if (value !== null && typeof value === "object") {
|
|
109
|
+
const record = value;
|
|
110
|
+
const sortedKeys = Object.keys(record).sort();
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const key of sortedKeys) {
|
|
113
|
+
out[key] = canonicalize(record[key]);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hash any JSON-serializable value to a stable lowercase SHA-256 hex digest. The
|
|
121
|
+
* hash is canonical for object key ordering (so `{a,b}` and `{b,a}` collide) and
|
|
122
|
+
* sensitive to values, types, and array order. Does not depend on runtime object
|
|
123
|
+
* identity — two separately constructed equal structures hash identically.
|
|
124
|
+
*/
|
|
125
|
+
export function stableJsonHash(value) {
|
|
126
|
+
const canonical = canonicalize(value);
|
|
127
|
+
const json = JSON.stringify(canonical) ?? "null";
|
|
128
|
+
return createHash("sha256").update(json).digest("hex");
|
|
129
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed, non-blocking local git hook installation and verification (BAPI-395).
|
|
3
|
+
*
|
|
4
|
+
* `conductor install-git-hooks` installs opportunistic `post-commit` and
|
|
5
|
+
* `reference-transaction` hooks that launch the conductor producer in the
|
|
6
|
+
* background and never block the user's git workflow. Hooks are LOCAL and
|
|
7
|
+
* unversioned: only a clearly-delimited managed block is inserted/replaced, all
|
|
8
|
+
* surrounding user hook content is preserved, and anything that looks binary or
|
|
9
|
+
* unsafe to merge is left untouched with a warning. Missing hooks are a degraded
|
|
10
|
+
* OPTIONAL capability — never a fatal error.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { runGitCommand } from "./git-inspection.js";
|
|
16
|
+
/** Markers delimiting the conductor-managed block inside a hook file. */
|
|
17
|
+
export const BRIDGE_CONDUCTOR_HOOK_START = "# >>> BRIDGE_CONDUCTOR_HOOK_START >>>";
|
|
18
|
+
export const BRIDGE_CONDUCTOR_HOOK_END = "# <<< BRIDGE_CONDUCTOR_HOOK_END <<<";
|
|
19
|
+
/** The managed hook names this module installs/inspects. */
|
|
20
|
+
export const MANAGED_HOOK_NAMES = ["post-commit", "reference-transaction"];
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the hooks directory using `git rev-parse --git-common-dir`, so the
|
|
23
|
+
* correct shared hooks directory is found even from inside a linked worktree. A
|
|
24
|
+
* relative common dir is resolved against `cwd`. Returns `{ is_worktree: false,
|
|
25
|
+
* hooks_dir: null }` when the current directory is not a git worktree.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveGitHooksDirectory(deps = {}) {
|
|
28
|
+
const runGit = deps.runGit ?? runGitCommand;
|
|
29
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
30
|
+
const result = runGit(["rev-parse", "--git-common-dir"], { cwd });
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
return { is_worktree: false, hooks_dir: null };
|
|
33
|
+
const commonDir = result.stdout.trim();
|
|
34
|
+
if (commonDir.length === 0)
|
|
35
|
+
return { is_worktree: false, hooks_dir: null };
|
|
36
|
+
const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve(cwd, commonDir);
|
|
37
|
+
return { is_worktree: true, hooks_dir: resolve(absoluteCommonDir, "hooks") };
|
|
38
|
+
}
|
|
39
|
+
/** Best-effort default path to the compiled conductor bin (`build/conductor-bin.js`). */
|
|
40
|
+
function defaultConductorBin() {
|
|
41
|
+
// This file compiles to build/conductor/git-hooks.js; the bin is build/conductor-bin.js.
|
|
42
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "conductor-bin.js");
|
|
43
|
+
}
|
|
44
|
+
function wrapManagedBlock(body) {
|
|
45
|
+
return [BRIDGE_CONDUCTOR_HOOK_START, body, BRIDGE_CONDUCTOR_HOOK_END].join("\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the managed `post-commit` snippet: launches
|
|
49
|
+
* `node <conductor-bin> git-hook post-commit` fully detached in the background,
|
|
50
|
+
* redirecting all output away from the user's git workflow. Failures are tolerated
|
|
51
|
+
* (`|| true`) so the hook never returns non-zero.
|
|
52
|
+
*/
|
|
53
|
+
export function buildPostCommitHookSnippet(conductorBin) {
|
|
54
|
+
const body = [
|
|
55
|
+
"# Managed by Bridge conductor (BAPI-395). Local, unversioned, opportunistic, bypassable.",
|
|
56
|
+
`( node ${JSON.stringify(conductorBin)} git-hook post-commit >/dev/null 2>&1 & ) || true`,
|
|
57
|
+
].join("\n");
|
|
58
|
+
return wrapManagedBlock(body);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the managed `reference-transaction` snippet: captures stdin to a temp
|
|
62
|
+
* file (the ref updates), then launches
|
|
63
|
+
* `node <conductor-bin> git-hook reference-transaction --phase "$1" --stdin-file <tmp>`
|
|
64
|
+
* detached in the background, redirecting output away from the user's workflow.
|
|
65
|
+
*/
|
|
66
|
+
export function buildReferenceTransactionHookSnippet(conductorBin) {
|
|
67
|
+
const body = [
|
|
68
|
+
"# Managed by Bridge conductor (BAPI-395). Local, unversioned, opportunistic, bypassable.",
|
|
69
|
+
'__bridge_tmp="$(mktemp 2>/dev/null || echo "/tmp/bridge-conductor-refs.$$")"',
|
|
70
|
+
'cat > "$__bridge_tmp"',
|
|
71
|
+
`( node ${JSON.stringify(conductorBin)} git-hook reference-transaction --phase "$1" --stdin-file "$__bridge_tmp" >/dev/null 2>&1 & ) || true`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
return wrapManagedBlock(body);
|
|
74
|
+
}
|
|
75
|
+
function looksBinary(content) {
|
|
76
|
+
return content.includes("\u0000");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Insert or replace ONLY the conductor-managed block within existing hook content,
|
|
80
|
+
* preserving all surrounding user content. A binary/unsafe existing hook is left
|
|
81
|
+
* untouched with a warning. Idempotent: an existing managed block is replaced, not
|
|
82
|
+
* duplicated.
|
|
83
|
+
*/
|
|
84
|
+
export function mergeManagedHookSnippet(existing, managedBlock) {
|
|
85
|
+
if (existing === null || existing.length === 0) {
|
|
86
|
+
const content = `#!/bin/sh\n${managedBlock}\n`;
|
|
87
|
+
return { ok: true, content, action: "created" };
|
|
88
|
+
}
|
|
89
|
+
if (looksBinary(existing)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
content: existing,
|
|
93
|
+
action: "skipped",
|
|
94
|
+
warning: "existing hook appears binary/unsafe to merge; left untouched",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const startIdx = existing.indexOf(BRIDGE_CONDUCTOR_HOOK_START);
|
|
98
|
+
const endIdx = existing.indexOf(BRIDGE_CONDUCTOR_HOOK_END);
|
|
99
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
100
|
+
const before = existing.slice(0, startIdx);
|
|
101
|
+
const after = existing.slice(endIdx + BRIDGE_CONDUCTOR_HOOK_END.length);
|
|
102
|
+
const content = `${before}${managedBlock}${after}`;
|
|
103
|
+
return { ok: true, content, action: "replaced" };
|
|
104
|
+
}
|
|
105
|
+
// No managed block yet — append after the user's content, preserving it.
|
|
106
|
+
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
107
|
+
const content = `${existing}${separator}${managedBlock}\n`;
|
|
108
|
+
return { ok: true, content, action: "appended" };
|
|
109
|
+
}
|
|
110
|
+
function snippetForHook(name, conductorBin) {
|
|
111
|
+
return name === "post-commit"
|
|
112
|
+
? buildPostCommitHookSnippet(conductorBin)
|
|
113
|
+
: buildReferenceTransactionHookSnippet(conductorBin);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Install or update the managed `post-commit` and `reference-transaction` hooks.
|
|
117
|
+
* Creates the hooks directory when needed and marks hook files executable on
|
|
118
|
+
* POSIX. A non-worktree directory is reported as a degraded optional capability
|
|
119
|
+
* (warning), never a fatal error.
|
|
120
|
+
*/
|
|
121
|
+
export function installConductorGitHooks(deps = {}) {
|
|
122
|
+
const platform = deps.platform ?? process.platform;
|
|
123
|
+
const conductorBin = deps.conductorBin ?? defaultConductorBin();
|
|
124
|
+
const { is_worktree, hooks_dir } = resolveGitHooksDirectory(deps);
|
|
125
|
+
if (!is_worktree || hooks_dir === null) {
|
|
126
|
+
return {
|
|
127
|
+
is_worktree: false,
|
|
128
|
+
hooks_dir: null,
|
|
129
|
+
installed: [],
|
|
130
|
+
warnings: ["not a git worktree; git hooks were not installed (degraded optional capability)"],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const warnings = [];
|
|
134
|
+
if (!existsSync(hooks_dir)) {
|
|
135
|
+
mkdirSync(hooks_dir, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
const installed = [];
|
|
138
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
139
|
+
const hookPath = resolve(hooks_dir, name);
|
|
140
|
+
let existing = null;
|
|
141
|
+
if (existsSync(hookPath)) {
|
|
142
|
+
try {
|
|
143
|
+
existing = readFileSync(hookPath, "utf-8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
installed.push({ name, path: hookPath, action: "skipped", warning: "could not read existing hook" });
|
|
147
|
+
warnings.push(`could not read existing ${name} hook; left untouched`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const merge = mergeManagedHookSnippet(existing, snippetForHook(name, conductorBin));
|
|
152
|
+
if (!merge.ok) {
|
|
153
|
+
installed.push({ name, path: hookPath, action: "skipped", warning: merge.warning });
|
|
154
|
+
if (merge.warning)
|
|
155
|
+
warnings.push(`${name}: ${merge.warning}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
writeFileSync(hookPath, merge.content, "utf-8");
|
|
159
|
+
if (platform !== "win32") {
|
|
160
|
+
try {
|
|
161
|
+
chmodSync(hookPath, 0o755);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
warnings.push(`could not mark ${name} executable`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
installed.push({ name, path: hookPath, action: merge.action });
|
|
168
|
+
}
|
|
169
|
+
return { is_worktree: true, hooks_dir, installed, warnings };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Inspect the managed git hooks WITHOUT modifying anything. Reports worktree
|
|
173
|
+
* status, the resolved hooks directory, and per-hook existence, executability, and
|
|
174
|
+
* managed-block presence. Missing hooks and non-worktree directories are reported
|
|
175
|
+
* as degraded optional capability, never fatal.
|
|
176
|
+
*/
|
|
177
|
+
export function inspectConductorGitHooks(deps = {}) {
|
|
178
|
+
const platform = deps.platform ?? process.platform;
|
|
179
|
+
const { is_worktree, hooks_dir } = resolveGitHooksDirectory(deps);
|
|
180
|
+
if (!is_worktree || hooks_dir === null) {
|
|
181
|
+
return {
|
|
182
|
+
is_worktree: false,
|
|
183
|
+
hooks_dir: null,
|
|
184
|
+
hooks: [],
|
|
185
|
+
warnings: ["not a git worktree; conductor git hooks are unavailable (degraded optional capability)"],
|
|
186
|
+
degraded: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const warnings = [];
|
|
190
|
+
const hooks = [];
|
|
191
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
192
|
+
const hookPath = resolve(hooks_dir, name);
|
|
193
|
+
const exists = existsSync(hookPath);
|
|
194
|
+
let executable = false;
|
|
195
|
+
let managedPresent = false;
|
|
196
|
+
if (exists) {
|
|
197
|
+
try {
|
|
198
|
+
const mode = statSync(hookPath).mode;
|
|
199
|
+
executable = platform === "win32" ? true : (mode & 0o111) !== 0;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* unreadable stat — leave executable false */
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
managedPresent = readFileSync(hookPath, "utf-8").includes(BRIDGE_CONDUCTOR_HOOK_START);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* unreadable — leave managedPresent false */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
warnings.push(`${name} hook not installed (degraded optional capability)`);
|
|
213
|
+
}
|
|
214
|
+
hooks.push({ name, path: hookPath, exists, executable, managed_block_present: managedPresent });
|
|
215
|
+
}
|
|
216
|
+
const degraded = hooks.some((h) => !h.exists || !h.managed_block_present);
|
|
217
|
+
return { is_worktree: true, hooks_dir, hooks, warnings, degraded };
|
|
218
|
+
}
|