@bridge_gpt/mcp-server 0.2.1 → 0.2.3
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 +558 -63
- 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 +3 -0
- 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 +683 -82
- 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 +18 -6
- 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 +16 -8
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local conductor ledger path resolution and filesystem hardening.
|
|
3
|
+
*
|
|
4
|
+
* The ledger lives next to the Tier-1 credential store at
|
|
5
|
+
* `~/.config/bridge/events.db` (honoring `XDG_CONFIG_HOME`), reusing the exact
|
|
6
|
+
* directory precedent from credential-store.ts so the two local-state files
|
|
7
|
+
* stay co-located and XDG-consistent. Directories are created `0700` and the DB
|
|
8
|
+
* file `0600` on POSIX; Windows relies on user-profile ACLs (no chmod). All
|
|
9
|
+
* health/warning output is secret-free: only paths, filesystem types, and mode
|
|
10
|
+
* bits are ever surfaced.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { getPrimaryCredentialStorePath } from "../credential-store.js";
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the ledger DB path: take the credential-store path (which honors
|
|
19
|
+
* `XDG_CONFIG_HOME` exactly) and swap `credentials.json` for `events.db`, so the
|
|
20
|
+
* directory derivation is shared rather than re-implemented.
|
|
21
|
+
*/
|
|
22
|
+
export function getConductorLedgerPath() {
|
|
23
|
+
const credentialsPath = getPrimaryCredentialStorePath({
|
|
24
|
+
env: process.env,
|
|
25
|
+
homedir: os.homedir,
|
|
26
|
+
});
|
|
27
|
+
return path.join(path.dirname(credentialsPath), "events.db");
|
|
28
|
+
}
|
|
29
|
+
/** The directory containing the ledger DB file. */
|
|
30
|
+
export function getConductorLedgerDirectory() {
|
|
31
|
+
return path.dirname(getConductorLedgerPath());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create the ledger directory recursively and, on POSIX, enforce `0700`. On
|
|
35
|
+
* Windows the chmod is skipped (user-profile ACLs apply instead).
|
|
36
|
+
*/
|
|
37
|
+
export function ensureConductorLedgerDirectory() {
|
|
38
|
+
const dir = getConductorLedgerDirectory();
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
if (process.platform !== "win32") {
|
|
41
|
+
fs.chmodSync(dir, 0o700);
|
|
42
|
+
}
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensure the `events.db` file exists with restrictive permissions BEFORE the
|
|
47
|
+
* SQLite driver opens it for writes. Creates the file `0600` if absent (without
|
|
48
|
+
* truncating an existing file) and re-applies `0600` on POSIX. Windows skips the
|
|
49
|
+
* chmod enforcement.
|
|
50
|
+
*/
|
|
51
|
+
export function ensureConductorDatabaseFile() {
|
|
52
|
+
ensureConductorLedgerDirectory();
|
|
53
|
+
const dbPath = getConductorLedgerPath();
|
|
54
|
+
if (!fs.existsSync(dbPath)) {
|
|
55
|
+
// `wx`-style create-if-absent without truncation; close immediately so the
|
|
56
|
+
// SQLite driver owns the handle. Mode is applied on creation on POSIX.
|
|
57
|
+
const fd = fs.openSync(dbPath, "a", 0o600);
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
if (process.platform !== "win32") {
|
|
61
|
+
fs.chmodSync(dbPath, 0o600);
|
|
62
|
+
}
|
|
63
|
+
return dbPath;
|
|
64
|
+
}
|
|
65
|
+
/** Render the low 9 permission bits of a stat mode as an octal string (e.g. "700"). */
|
|
66
|
+
function formatMode(mode) {
|
|
67
|
+
return (mode & 0o777).toString(8).padStart(3, "0");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Inspect ledger directory/file existence and permissions without creating or
|
|
71
|
+
* modifying anything. Insecure (group/world-accessible) modes are reported as
|
|
72
|
+
* `*_permissions_ok: false` plus a `warnings` entry rather than throwing.
|
|
73
|
+
*/
|
|
74
|
+
export function getConductorPathHealth() {
|
|
75
|
+
const dbPath = getConductorLedgerPath();
|
|
76
|
+
const dir = path.dirname(dbPath);
|
|
77
|
+
const warnings = [];
|
|
78
|
+
let directoryExists = false;
|
|
79
|
+
let directoryMode = null;
|
|
80
|
+
let directoryPermissionsOk = true;
|
|
81
|
+
try {
|
|
82
|
+
const dirStat = fs.statSync(dir);
|
|
83
|
+
directoryExists = true;
|
|
84
|
+
directoryMode = formatMode(dirStat.mode);
|
|
85
|
+
if (process.platform !== "win32" && (dirStat.mode & 0o077) !== 0) {
|
|
86
|
+
directoryPermissionsOk = false;
|
|
87
|
+
warnings.push(`Conductor ledger directory ${dir} is group/world-accessible; it should be mode 0700.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
directoryExists = false;
|
|
92
|
+
}
|
|
93
|
+
let databaseExists = false;
|
|
94
|
+
let databaseMode = null;
|
|
95
|
+
let databasePermissionsOk = true;
|
|
96
|
+
try {
|
|
97
|
+
const fileStat = fs.statSync(dbPath);
|
|
98
|
+
databaseExists = true;
|
|
99
|
+
databaseMode = formatMode(fileStat.mode);
|
|
100
|
+
if (process.platform !== "win32" && (fileStat.mode & 0o077) !== 0) {
|
|
101
|
+
databasePermissionsOk = false;
|
|
102
|
+
warnings.push(`Conductor ledger file ${dbPath} is group/world-accessible; it should be mode 0600.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
databaseExists = false;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
path: dbPath,
|
|
110
|
+
directory_path: dir,
|
|
111
|
+
directory_exists: directoryExists,
|
|
112
|
+
database_exists: databaseExists,
|
|
113
|
+
directory_mode: directoryMode,
|
|
114
|
+
database_mode: databaseMode,
|
|
115
|
+
directory_permissions_ok: directoryPermissionsOk,
|
|
116
|
+
database_permissions_ok: databasePermissionsOk,
|
|
117
|
+
warnings,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Filesystem-type fragments that indicate a network-mounted home directory. */
|
|
121
|
+
const NETWORK_FS_MARKERS = [
|
|
122
|
+
"nfs",
|
|
123
|
+
"cifs",
|
|
124
|
+
"smb",
|
|
125
|
+
"smbfs",
|
|
126
|
+
"afpfs",
|
|
127
|
+
"sshfs",
|
|
128
|
+
"fuse.sshfs",
|
|
129
|
+
"fuse.gvfs",
|
|
130
|
+
"9p",
|
|
131
|
+
"webdav",
|
|
132
|
+
];
|
|
133
|
+
function matchesNetworkFsType(raw) {
|
|
134
|
+
const lowered = raw.trim().toLowerCase();
|
|
135
|
+
if (lowered.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
for (const marker of NETWORK_FS_MARKERS) {
|
|
138
|
+
if (lowered.includes(marker)) {
|
|
139
|
+
return lowered;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Best-effort detection of a network-mounted home directory. Concurrent
|
|
146
|
+
* SQLite/WAL writes on NFS/SMB are unreliable, so we surface a degraded warning
|
|
147
|
+
* rather than failing. Detection that cannot run returns a non-throwing
|
|
148
|
+
* "unknown" result (`network_mounted: false`, no warning).
|
|
149
|
+
*
|
|
150
|
+
* - win32: flag UNC home paths (`\\server\share`).
|
|
151
|
+
* - linux: `stat -f -c %T <home>` and match the filesystem type.
|
|
152
|
+
* - darwin: `stat -f %T <home>` and match the filesystem type.
|
|
153
|
+
*/
|
|
154
|
+
export function detectNetworkMountedHome() {
|
|
155
|
+
let home = "";
|
|
156
|
+
try {
|
|
157
|
+
home = os.homedir();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return { network_mounted: false, filesystem_type: null, home: "", warning: null };
|
|
161
|
+
}
|
|
162
|
+
if (process.platform === "win32") {
|
|
163
|
+
if (home.startsWith("\\\\") || home.startsWith("//")) {
|
|
164
|
+
return {
|
|
165
|
+
network_mounted: true,
|
|
166
|
+
filesystem_type: "unc",
|
|
167
|
+
home,
|
|
168
|
+
warning: `Home directory ${home} appears to be a UNC network share; ` +
|
|
169
|
+
`concurrent SQLite writes to the conductor ledger may be unreliable.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { network_mounted: false, filesystem_type: null, home, warning: null };
|
|
173
|
+
}
|
|
174
|
+
const args = process.platform === "darwin"
|
|
175
|
+
? ["-f", "%T", home]
|
|
176
|
+
: ["-f", "-c", "%T", home];
|
|
177
|
+
try {
|
|
178
|
+
const output = execFileSync("stat", args, {
|
|
179
|
+
encoding: "utf-8",
|
|
180
|
+
timeout: 1500,
|
|
181
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
182
|
+
});
|
|
183
|
+
const fsType = matchesNetworkFsType(output);
|
|
184
|
+
if (fsType) {
|
|
185
|
+
return {
|
|
186
|
+
network_mounted: true,
|
|
187
|
+
filesystem_type: fsType,
|
|
188
|
+
home,
|
|
189
|
+
warning: `Home directory ${home} appears to be on a network filesystem (${fsType}); ` +
|
|
190
|
+
`concurrent SQLite writes to the conductor ledger may be unreliable.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
network_mounted: false,
|
|
195
|
+
filesystem_type: output.trim().toLowerCase() || null,
|
|
196
|
+
home,
|
|
197
|
+
warning: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Detection failed (no `stat`, timeout, permission). Degrade gracefully.
|
|
202
|
+
return { network_mounted: false, filesystem_type: null, home, warning: null };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Process-level guard so the network-home warning is emitted at most once. */
|
|
206
|
+
let networkHomeWarned = false;
|
|
207
|
+
/** Reset the one-time warning guard. Test-only seam. */
|
|
208
|
+
export function resetNetworkHomeWarning() {
|
|
209
|
+
networkHomeWarned = false;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Emit a single process-level warning when the home directory looks
|
|
213
|
+
* network-mounted, and return the detection so MCP/CLI responses can report
|
|
214
|
+
* `degraded: true`. Only filesystem-type/path metadata is logged — never event
|
|
215
|
+
* payloads or secrets.
|
|
216
|
+
*/
|
|
217
|
+
export function warnIfNetworkMountedHome() {
|
|
218
|
+
const detection = detectNetworkMountedHome();
|
|
219
|
+
if (detection.network_mounted && detection.warning && !networkHomeWarned) {
|
|
220
|
+
networkHomeWarned = true;
|
|
221
|
+
console.error(`Warning: ${detection.warning}`);
|
|
222
|
+
}
|
|
223
|
+
return detection;
|
|
224
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epic Supervisor plan representation — machine-readable DAG, canonical
|
|
3
|
+
* serialization, and content-addressed hashing.
|
|
4
|
+
*
|
|
5
|
+
* The plan is an immutable, content-addressed blob keyed by {@link hashPlan}.
|
|
6
|
+
* The sibling `epic-tick` reconcile loop enforces the gate:
|
|
7
|
+
* `stableJsonHash(currentPlan) === epic_runs.approved_plan_hash`
|
|
8
|
+
* and FAIL-CLOSES (dispatches nothing, escalates, waits for re-approval) on
|
|
9
|
+
* any mismatch. The hash function exported here is the canonical implementation
|
|
10
|
+
* both sides import so the loop-side hash and the approved hash are always
|
|
11
|
+
* byte-identical (mirroring how `makeMergeActionKey` is kept in lock-step with
|
|
12
|
+
* the Python `build_merge_action_key`).
|
|
13
|
+
*
|
|
14
|
+
* The source of `EpicPlanDAG` values is LLM generation recipes (e.g.
|
|
15
|
+
* `/plan-epic`). The supervisor computes the ready-set deterministically from
|
|
16
|
+
* the DAG (`depends_on` all `done`) and never asks an LLM "what's next."
|
|
17
|
+
*
|
|
18
|
+
* Architecture note: the plan blob is stored as a dedicated durable-store row
|
|
19
|
+
* (not a decision-page artifact) via the protected `/jira/epic-runs/runs/{epicKey}/plan`
|
|
20
|
+
* endpoint, keeping the decision-page surface for human-facing approvals only.
|
|
21
|
+
* Plan data is immutable per `(epic_run_id, plan_version)` — a re-plan writes a
|
|
22
|
+
* new `plan_version + 1`, never mutating an existing blob.
|
|
23
|
+
*/
|
|
24
|
+
import { stableJsonHash } from "./git-ci-types.js";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Canonicalization
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Produce a fully-normalized copy of the plan DAG so that equivalent plans
|
|
30
|
+
* hash identically regardless of input ordering or whitespace:
|
|
31
|
+
*
|
|
32
|
+
* - Trims outer whitespace from every `ticket_key` (nodes and edges).
|
|
33
|
+
* - Sorts each node's `depends_on` array alphabetically.
|
|
34
|
+
* - Sorts `nodes` lexicographically by normalized `ticket_key`.
|
|
35
|
+
* - Sorts `edges` lexicographically by `from` then `to`.
|
|
36
|
+
*
|
|
37
|
+
* The canonical form is fed to {@link hashPlan} / `stableJsonHash`, which
|
|
38
|
+
* additionally sorts object keys recursively, so two independently constructed
|
|
39
|
+
* equal plans always produce a byte-identical SHA-256 digest.
|
|
40
|
+
*/
|
|
41
|
+
export function canonicalizePlanDAG(plan) {
|
|
42
|
+
const nodes = plan.nodes
|
|
43
|
+
.map((node) => ({
|
|
44
|
+
...node,
|
|
45
|
+
ticket_key: node.ticket_key.trim(),
|
|
46
|
+
depends_on: [...node.depends_on].map((k) => k.trim()).sort(),
|
|
47
|
+
}))
|
|
48
|
+
.sort((a, b) => a.ticket_key.localeCompare(b.ticket_key));
|
|
49
|
+
const edges = [...plan.edges]
|
|
50
|
+
.map((e) => ({ from: e.from.trim(), to: e.to.trim() }))
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const cmp = a.from.localeCompare(b.from);
|
|
53
|
+
return cmp !== 0 ? cmp : a.to.localeCompare(b.to);
|
|
54
|
+
});
|
|
55
|
+
return { plan_version: plan.plan_version, nodes, edges };
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Content-addressed hashing
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Compute the canonical SHA-256 content-address of the plan. Canonicalizes
|
|
62
|
+
* before hashing so the result is insensitive to object key order, array
|
|
63
|
+
* order in `depends_on`, node order, and whitespace in ticket keys — but
|
|
64
|
+
* sensitive to any semantic change (added/removed node, edge, automation
|
|
65
|
+
* kind, or `base_lineage`). Two independently constructed equal plans always
|
|
66
|
+
* hash identically (cross-machine requirement from OQ1/OQ3).
|
|
67
|
+
*
|
|
68
|
+
* Reuses {@link stableJsonHash} from `git-ci-types.ts` — never invents a
|
|
69
|
+
* second hashing path.
|
|
70
|
+
*
|
|
71
|
+
* Note: `status` is part of the hash (by design — it is part of the type).
|
|
72
|
+
* The gate's stability guarantee holds because the approved blob always has all
|
|
73
|
+
* statuses as `"planned"`; the supervisor tracks live progress externally.
|
|
74
|
+
*/
|
|
75
|
+
export function hashPlan(plan) {
|
|
76
|
+
return stableJsonHash(canonicalizePlanDAG(plan));
|
|
77
|
+
}
|